Targ Apps Docs
Chroma

Reactivity Model

How signals, bindings, and dependency tracking work under the hood.

Chroma's reactivity is fine-grained: instead of re-rendering components and diffing trees, individual bindings subscribe to individual signals and re-run when those signals change.

The three concepts

Signals

A signal is a mutable value slot with an identity. hookState, useReducer, and each useMemo allocate one. Signals live in the Rust runtime (RUNTIME.values: Vec<JsValue>); JS only ever holds getters/setters that reference them by numeric id.

Bindings

A binding is a JS expression (a function) plus a target describing what to do with its result:

TargetCreated byOn evaluation
Attributefunction prop on an elementSets/removes the attribute
DynamicChildfunction child of an elementReplaces the slot's content (text or nodes)
EffectuseEffect / useLayoutEffect / Show / ForRuns the side effect; stores the returned cleanup
MemouseMemoWrites the result into the memo's signal

The dependency graph

A pure id-to-id structure (no values, no DOM) that maps:

  • signal → subscribed bindings
  • binding → signals read in its last evaluation

The tracking cycle

  1. Evaluation opens a tracking scope. Before a binding's expression runs, the runtime pushes its id onto a tracking stack and clears the binding's previous dependencies (they may change between runs — e.g. a branch that reads different signals).
  2. Reads register. Every getter() call inside the expression lands in read_signal, which subscribes the binding on top of the stack to that signal. Duplicate reads subscribe once. Nested evaluations attribute reads to the innermost binding.
  3. The scope closes. The stack is popped; the result is applied to the target.
  4. Writes notify. setter(value) compares with Object.is; if changed, it stores the value and synchronously re-runs every subscribed binding — restarting the cycle at step 1 for each.

Because dependencies are rebuilt on every run, subscriptions always reflect the latest execution path — a binding that stops reading a signal automatically unsubscribes from it.

Untracked execution

Some code must run inside an evaluation without subscribing:

  • Effect bodies when an explicit deps array is given (the array defines the subscriptions, not the body).
  • Cleanup functions.
  • For's item render function (item nodes shouldn't retrack the list binding).
  • Internal reads like dispatch reading the current state before computing the next one.
  • Show's initial evaluation of its fallback/children functions (mounting both branches must not subscribe them to anything).

The runtime implements this by pushing a sentinel UNTRACKED binding (BindingId::MAX) onto the tracking stack: reads under it are discarded by track_read.

Why "children as functions" is the reactive unit

There is no re-render to re-evaluate JSX. So the function is the only thing the runtime can re-run:

e("p", null, count)              // binding: DynamicChild, subscribed to count
e("p", null, () => count() * 2)  // binding: DynamicChild, derived
e("p", null, count())            // no binding — a number was passed

Reactive children mount into a <span style="display: contents"> slot so replacement is local: updating one binding never touches sibling nodes.

Effect timing

  • useLayoutEffect expressions are queued during component execution and flushed by render right after the tree is appended — synchronously, before paint. The flush loops, so effects queued by other effects also run.
  • useEffect defers its binding creation to a microtask (window.queueMicrotask), which lands after mounting.
  • All re-runs (both kinds) are synchronous with the signal write that triggered them.

Change detection

Change detection is Object.is at the signal level. Writing the same primitive is a no-op; writing a mutated object or array does not notify (same reference). To update collections, write a new reference.

setTodos([...todos(), newTodo]);   // ✅ new array → notifies
todos().push(newTodo);             // ❌ same reference → silent

See Control Flow for how this plays out with For, and the Hooks Guide for hookState's setter semantics.

On this page