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. touch() is only called when the new
value !== the existing one — so assigning a value equal to the current one
does nothing at all: 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 — three ways. Keep the unsubscribe, keep the listener, or say so from inside the callback:
// 1. the unsubscribe returned by .observe()
const stop = app.thing.observe(() => { /* … */ })
stop()
// 2. keep the listener and unobserve it
const listener = observe('app.thing', () => { /* … */ })
unobserve(listener)
// 3. from INSIDE the callback — the natural form for "stop when my
// element is gone", and the only one that needs no bookkeeping
observe('app.thing', () => {
if (!el.isConnected) return OBSERVER_SHOULD_BE_REMOVED
el.textContent = app.thing.value
})
Form 3 is pull-based: it costs one more call to notice it should go.
None of this is required with bind, which is the point of the section above —
detaching the element is the teardown.
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.