Skip to content

Reactivity and reads

Stator’s reactivity has no magic in it — no proxies tracking field access, no signal graph, no dependency inference. You declare, on each node, exactly which state it shows. This page explains how that declaration becomes a minimal DOM update.

A node that shows reactive state says so with read(machine, selector):

<p>Total: {read(cart, c => c.total)}</p>

Nothing else is reactive. A bare {cart.total} would render once and never update. This is a deliberate trade: you type a little more, and in exchange the set of things that can update — and the reason each one does — is legible directly from the template. There is no “why did this re-render?” because the dependency is the selector you wrote, nothing more, nothing less.

read() does two things during the render pass: it runs the selector to get the current value for the initial HTML, and it registers a binding against the surrounding node — capturing the machine, the selector, the value, and an allocated slot id. The machine instance is a proxy that reads through the live snapshot, so re-running the selector later always sees fresh state.

Bindings come in a few kinds depending on position: text (a slot in text content), attr (a whole attribute value), and the list/branch bindings produced by each/when/match. Each kind knows how to diff and how to patch itself.

A binding is re-diffed by exactly one owner, and a read must sit inside its owner’s render scope. Machine reads are owned by the page’s binding table and may appear anywhere that re-diffs — which is everywhere except a defer() arm, because a defer slot is one-shot and a live value there could never update. Item readsread(row, …) inside an each — are owned by their row: the row render supplies the item, and the list’s recompute re-diffs the binding.

A when()/match() arm re-renders on its own schedule, without the row around it, so an item read inside an arm is a compile error — use a machine read there, or restructure so the arm doesn’t split the row. The compiler enforces both rules at build time and explains the escape hatches in the error. The full placement rules are in the keyed lists guide.

Every binding is filed two ways:

  • By its slot id — the address of the spot in the DOM it controls.
  • By its source machine — an index (byMachine) from a machine name to the set of slots that read it.

That second index is the efficiency win. When an event touches CartMachine, the server doesn’t re-check every binding on the page — it looks up exactly the bindings that read the cart and recomputes only those.

After a transition, the recompute pass walks the bindings for each touched machine, re-evaluates each selector, and compares the result against the value stored at render time:

  • If the value is unchanged, nothing is emitted.
  • If it changed, a patch is produced targeting that binding’s slot, and the stored value is updated.

Equality is pragmatic: Object.is first, then a shallow type check, then a JSON.stringify comparison for objects and arrays. The point is to suppress patches for values that are structurally the same, so the wire only carries genuine changes.

A binding produced by when/match reduces to a key: the branch re-renders only when the chosen branch changes, not every time the underlying value is merely truthy in a different way.

Inside a client island, the same spelling runs without a server. {read(theme, (t) => t.label)} subscribes to the island’s local actor and writes the DOM when the selector’s value changes — a local subscribe-and-write with no recompute pass and no wire. The compiler lowers read() by the machine’s location, so the primitive is ONE: declare on a node what state it shows. A server machine’s node updates by patch; a client machine’s by subscription. The mental model is identical on both sides of the boundary.

One word, four altitudes: the reads family

Section titled “One word, four altitudes: the reads family”

Four different constructs share the word read. That’s by design — each one means “reading machine state,” at a different altitude — but they are distinct mechanisms, and it helps to see them side by side:

You writeWhereWhat it does
read(instance, selector)a template nodeRenders the current value and registers a live binding at that DOM position — this page’s subject.
Stator.reads([Defs])route frontmatterBinds the route to machine definitions and hands back live instances. Also the route’s declared dependency set: SSE fan-out watches exactly these machines.
reads: [Defs]defineMachineDeclares that this machine may synchronously read those machines’ state — and server-pins it, since cross-machine reads can’t resolve in a browser.
helpers.readsactions, guards, selectorsThe resolved, typed access a reads: declaration grants at runtime — how the cart prices an item against the catalog.

When the docs say “a read” unqualified, they mean the first row — the template primitive.