Why bind, and when to use observe
There is one rule, and it decides almost every case:
Using
observeto persist a value is entirely legitimate. If youobserveand then shove something into the DOM, you are almost certainly adding code and bugs for no good reason.
observe is for reactions that touch no DOM. Everything else is bind.
The mistake, and why it does not look like one
// DON'T — this works, which is the problem
app.user.name.observe(() => {
nameField.value = app.user.name.value
})
nameField.addEventListener('input', () => {
app.user.name.value = nameField.value
})
// DO
input({ bindValue: app.user.name })
The first version runs. The page updates. Nothing throws. It is also a second implementation of two-way binding, and you now own it.
What you gave up
The element becomes invisible to your own app. An element is wired by
being bound and by nothing else, so describe() — the agent surface, the
accessibility audit, the schematic — cannot see it. Two divs both driven by
state, one bindText and one observe + textContent: describe() returns
one. Your app describes itself as smaller than it is.
You have to prime it yourself. bind applies on setup; observe does not.
That is why hand-rolled versions always carry a syncFromTheme() or show()
call next to the observer — and if you forget it, the UI is stale until the
first change.
You have to tear it down yourself. bind's dispatcher scans the live
document at dispatch time, so removing the element IS the teardown — it
holds no strong reference to your element. An observer runs until something
retires it. Detach the element without retiring it and the callback keeps
firing, writing into a node nobody can see, holding it in memory. Nothing warns
you.
You forfeit the machinery. Surgical list updates via idPath,
accumulate-don't-clobber when several bindings share an element,
take() memoisation, shadow-boundary handling, per-element error isolation so
one failure cannot strand the rest of the page.
The real argument
The list above is not the point, because a determined author can reimplement any of it. The point is that they would have to:
bindis one implementation, battle-tested over ten years across many complex environments. Every hand-rolled observer is a new implementation, whose edge-case coverage is whatever its author happened to think of — and the author cannot know what is missing, because the missing cases are silent.
And fixes do not propagate. Fix a shadow-DOM edge case in bind and every
binding in every app gets it. Fix it in your observer and you have fixed one
observer.
When observe is right
When the reaction touches no DOM at all:
| persist | write to localStorage, IndexedDB, a server |
| sync | replicate to another realm — another tab, the network, the URL |
| telemetry | analytics, logging, an audit trail |
| process | start or stop something — a timer, a worker, a poll |
| derived state | compute other state — though take is usually better |
// legitimate: nothing here touches the DOM
app.preferences.observe(() => {
localStorage.setItem('prefs', JSON.stringify(app.preferences.value))
})
"But bind can't do what I need"
It probably can, because toDOM is an arbitrary function and the side effect
does not have to land on the element you bind to:
// a global effect — bind the element that owns it
bind(document.documentElement, 'theme.accent', {
toDOM: (el, value) => el.style.setProperty('--accent', value),
})
// a structural rebuild
bind(myComponent, 'app.locale', { toDOM: () => myComponent.queueRender() })
// handing state to a library that owns its own DOM
bind(canvas, 'scene.time', { toDOM: (el, t) => renderFrame(el, t) })
All three keep the element in the map, apply on setup, and stop when the element goes away. The canvas one also stops redrawing when the value has not changed — see below.
Two things worth knowing either way
An unchanged scalar notifies nobody. Assigning a value that !== the
current one does not call touch() at all, so no binding runs, no observer
fires, and the DOM is never written. This is why binding an <input> does not
disturb its selection. It is identity, not deep equality: assigning a
deep-equal object does notify. It applies to every toDOM, so an expensive
custom redraw is skipped for free.
Observers can retire themselves. unobserve(listener), or the unsubscribe
function returned by proxy.observe(callback):
const stop = app.thing.observe(() => { /* … */ })
stop() // retired
There is a third form — returning a sentinel from the callback — which is implemented but not currently exported, so it cannot be used from a published build (tosijs#45).
The proposal to make observe state its reason is
tosijs#44; comment there.
The behaviours described here are pinned by tests/bind-lifecycle.pw.ts, which
runs in real Chromium and Firefox because attach/detach semantics are not
trustworthy in a DOM shim.