Reactivity
The one rule of Chroma's reactivity, and how signals, bindings, and dependency tracking behind it work.
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 one rule
A child or an attribute is reactive only if you pass it as a function.
e("p", null, count) // reactive: updates when count changes
e("p", null, count()) // static: evaluated once, never updates
e("p", null, () => count() * 2) // reactive derived expressionThe same applies to attributes:
e("main", { class: () => theme() }) // reactive attribute
e("main", { class: theme() }) // static, evaluated onceComponents run once, so there is no re-render that would re-evaluate an expression. The getter itself is the subscription. Every other page in these docs links here for this rule.
Signals, bindings, and the graph
A signal is a mutable value slot. hookState, useReducer, and each useMemo allocate one. You only ever hold a getter and a setter.
A binding is a function plus a target describing what to do with its result:
| Target | Created by | On evaluation |
|---|---|---|
| Attribute | Function prop on an element | Sets or removes the attribute |
| Dynamic child | Function child of an element | Replaces the slot's content (text or nodes) |
| Effect | useEffect, useLayoutEffect, Show, For | Runs the side effect and stores the returned cleanup |
| Memo | useMemo | Writes the result into the memo's signal |
The dependency graph records which bindings are subscribed to which signals.
The tracking cycle
- Evaluation opens a tracking scope. Before a binding runs, its previous dependencies are cleared, because they can change between runs.
- Reads register. Every getter call inside the function subscribes the innermost running binding to that signal. Duplicate reads subscribe once.
- The scope closes and the result is applied to the target.
- Writes notify. A setter compares with
Object.is. If the value changed, it stores it and synchronously re-runs every subscribed binding.
Dependencies are rebuilt on every run, so a binding that stops reading a signal unsubscribes from it automatically.
Untracked execution
Some code reads signals without subscribing:
- Effect and memo bodies when an explicit deps array is given (the array defines the subscriptions).
- Cleanup functions.
For's item render function.Show's initial evaluation offallbackand children.
Effect timing
useLayoutEffectruns synchronously afterrenderappends the tree, before paint. Effects queued by other effects also run.useEffectruns its first time in a microtask after mounting.- Re-runs of both kinds are synchronous with the signal write that triggered them.
Change detection
Change detection is Object.is on the signal's value. Writing the same primitive is a no-op. Mutating an object or array in place does not notify, because the reference is the same. Write a new reference.
setTodos([...todos(), newTodo]); // new array: notifies
todos().push(newTodo); // same reference: silentErrors in reactive code
Errors thrown inside effect and memo bodies, effect cleanups, reducers, binding expressions, and Provider children are caught and logged to the console with a chroma: prefix. The app keeps running and the failed run produces no update. Misuse of e, render, Show, and For (for example a missing required prop) throws an error instead. Exact messages: API reference.
Next
- Components: props, children, and the run-once model.
- Hooks: dependency arrays and every hook.
- Derived state:
useMemochains and views.