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:
| Target | Created by | On evaluation |
|---|---|---|
Attribute | function prop on an element | Sets/removes the attribute |
DynamicChild | function child of an element | Replaces the slot's content (text or nodes) |
Effect | useEffect / useLayoutEffect / Show / For | Runs the side effect; stores the returned cleanup |
Memo | useMemo | Writes 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
- 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).
- Reads register. Every
getter()call inside the expression lands inread_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. - The scope closes. The stack is popped; the result is applied to the target.
- Writes notify.
setter(value)compares withObject.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
dispatchreading the current state before computing the next one. Show's initial evaluation of itsfallback/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 passedReactive children mount into a <span style="display: contents"> slot so replacement is local: updating one binding never touches sibling nodes.
Effect timing
useLayoutEffectexpressions are queued during component execution and flushed byrenderright after the tree is appended — synchronously, before paint. The flush loops, so effects queued by other effects also run.useEffectdefers 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 → silentSee Control Flow for how this plays out with For, and the Hooks Guide for hookState's setter semantics.