Derived State & Dynamic Updates
How Chroma propagates changes through useMemo chains and reactive function children — no re-renders, no stale reads.
Chroma has no separate computed or watch API. Derived state is useMemo chains; UI updates are reactive function children (or function props). Components run once; when a source signal changes, bindings re-run synchronously and every getter read after the write reflects the new value.
The three-layer pattern
| Layer | Primitive | Role |
|---|---|---|
| Source | hookState / useReducer | Mutable inputs the user or store writes to |
| Derived | useMemo | Cached computation; returns a getter backed by an internal signal |
| View | Function child or function prop | Binding that re-runs when its subscribed getters change |
import { hookState, useMemo, e, For } from "./pkg/chroma.js";
function ItemList() {
const [query, setQuery] = hookState("");
const [items, setItems] = hookState([
{ id: 1, label: "Rent" },
{ id: 2, label: "Groceries" },
]);
const filtered = useMemo(() => {
const q = query().trim().toLowerCase();
if (!q) return items();
return items().filter((item) => item.label.toLowerCase().includes(q));
}, [query, items]);
return e("section", null,
e("input", {
value: query,
onInput: (ev) => setQuery(ev.target.value),
placeholder: "Search…",
}),
// Reactive child: subscribes to `filtered`, not to `query` directly.
() => e("ul", null,
For({
each: filtered,
key: (item) => item.id,
children: (item) => e("li", null, item.label),
}),
),
);
}useMemo returns a getter, not a value. Call filtered() inside the reactive child so the binding subscribes to the memo's signal. Passing filtered as a child (without calling it) also works — Chroma treats function-valued children as bindings.
How updates propagate
setQuery("gro")writes thequerysignal.- The
useMemobinding subscribed toquery(via the deps array) re-runs, writes a new array into the memo's internal signal. - The
() => e("ul", …)child binding subscribed tofilteredre-runs and replaces only that slot's DOM. - Sibling nodes (the
<input>) are untouched — fine-grained, not a tree re-render.
Because memo re-runs are synchronous with the setter, reading filtered() immediately after setQuery(...) always returns the fresh value — there is no "stale closure" from a missed render.
Chained memos
Derived values can depend on other derived values. List the getter of the upstream memo in the deps array:
const [width, setWidth] = hookState(100);
const [height, setHeight] = hookState(50);
const area = useMemo(() => width() * height(), [width, height]);
const label = useMemo(() => `Area: ${area()} px²`, [area]);
// Reactive attribute bound to the outermost derived getter:
return e("p", { "data-area": label }, () => label());When setWidth(200) runs:
area's binding re-runs (subscribed towidth).area's signal updates →label's binding re-runs (subscribed toarea).- The
<p>attribute and text child bindings re-run (subscribed tolabel).
Each hop is a separate binding in the dependency graph; only subscribers of the changed signals re-run.
Reactive children vs one-shot values
e("p", null, count) // ✅ binding: DynamicChild, subscribes to count
e("p", null, () => count() * 2) // ✅ binding: derived inline
e("p", null, doubled) // ✅ binding: getter function (useMemo result)
e("p", null, count()) // ❌ frozen number — never updates
e("p", null, doubled()) // ❌ frozen value — evaluated once at mountFunction props follow the same rule — pass a getter for reactive attributes:
e("main", { class: () => `theme-${theme()}` });
// or, with a memo:
const className = useMemo(() => `theme-${theme()}`, [theme]);
e("main", { class: className });Side effects on derived data
Use useEffect / useLayoutEffect when derived state should trigger imperative work (animations, document.title, localStorage). Pass getters in the deps array:
const listState = useMemo(() => buildGroups(filtered(), page()), [filtered, page]);
useLayoutEffect(() => {
animateListStagger(listRef.current);
}, [listState]);The effect body runs untracked; subscriptions come only from the deps getters — same contract as React, but deps are functions not values. See Hooks Guide.
What Chroma does not ship
There is no computed(), watch(), or createDerived() helper. Those names appear in other fine-grained libraries; in Chroma the equivalents are:
| Elsewhere | Chroma |
|---|---|
computed(() => …) | useMemo(() => …, [deps]) or inline () => … child |
watch(source, fn) | useEffect(() => { … }, [source]) |
effect(() => …) (auto-track) | useEffect(() => …) with deps omitted |
Adding a second derived-state primitive would duplicate useMemo without changing the runtime.
Show vs For: don't freeze a derived list
useMemo can be correct while the DOM stays stale. Show mounts its children function once (under untracked) and only toggles visibility when when() changes — it does not re-run the child to pick up new memo values. That is by design: inner hooks and DOM structure stay stable across show/hide.
Wrapping a list built from listState() inside Show children captures a static snapshot at first mount. Memos and useLayoutEffect still update; the visible rows do not.
Wrong (Finance bug)
const listState = useMemo(() => groupVisible(filtered(), limit()), [filtered, limit]);
e(Show, { when: hasTransactions, fallback: () => null },
() => e("div", { ref: groupsRef },
// ❌ listState().groups read once when Show mounts its child — frozen forever
listState().groups.map((group) => renderGroup(group)),
),
);Symptom: changing filters updated filteredTransactions / listState (and the animation effect re-ran) but transaction rows on screen never changed.
Right
Keep Show for the empty/has-data gate. Drive the list body with For and a getter each so the list binding subscribes to listState:
const filteredTransactions = useMemo(
() => filterTransactions(transactions(), { /* filter getters */ }),
[transactions, filterDateFrom, filterDateTo, /* … */],
);
const listState = useMemo(
() => groupVisibleTransactions(filteredTransactions(), visibleLimit()),
[filteredTransactions, visibleLimit],
);
useLayoutEffect(() => {
if (!groupsRef.current || listState().groups.length === 0) return;
animateListStagger(groupsRef.current, { itemSelector: '[data-component="transaction-row"]' });
}, [listState, filteredTransactions]);
e(Show, { when: hasTransactions, fallback: () => null },
() => e("div", { ref: groupsRef },
e(For, {
each: () => listState().groups, // ✅ getter — re-runs when listState's signal changes
children: (group) => renderGroup(group),
}),
e(Show, { when: () => listState().hasMore }, () => loadMoreButton),
),
);See Control Flow — Show for mount-once semantics and Targ-Apps-Finance — filter reactivity for the shipped fix in pages/Transactions.js.
Production example: filtered transaction list
Targ-Apps-Finance (pages/Transactions.js) uses the full pattern above:
| Layer | Implementation |
|---|---|
| Source | hookState per filter field + finance.transactions getter |
| Derived | filteredTransactions → listState (chained useMemo, getter deps) |
| View | For({ each: () => listState().groups }) inside a Show gate |
| Effects | useLayoutEffect deps [listState, filteredTransactions] for row animations |
Details: Targ-Apps-Finance — Transactions · ledger wiring: Ledger — Cableado.
Tests
Browser WASM tests cover this contract in:
Chroma/src/signals.rs—memo_binding_writes_its_result_into_the_target_signalChroma/src/action/hooks/hook_memo.rs— declared-deps and auto-track memosChroma/src/reactive_updates.rs— integration:hookState→ chaineduseMemo→ attribute / dynamic-child bindings, synchronous reads, no stale values
Run native graph tests with cargo test and browser tests with wasm-pack test --headless (see Architecture — Testing).
Where to go next
- Reactivity Model — signals, bindings, and the tracking cycle
- Hooks Guide —
useMemoand dependency-array semantics - Common Pitfalls — getter deps,
Showlist snapshots, re-entrant writes