Why bind, and when to use observe

There is one rule, and it decides almost every case:

Using observe to persist a value is entirely legitimate. If you observe and 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:

bind is 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.