Targ Apps Docs
Chroma

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

LayerPrimitiveRole
SourcehookState / useReducerMutable inputs the user or store writes to
DeriveduseMemoCached computation; returns a getter backed by an internal signal
ViewFunction child or function propBinding 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

  1. setQuery("gro") writes the query signal.
  2. The useMemo binding subscribed to query (via the deps array) re-runs, writes a new array into the memo's internal signal.
  3. The () => e("ul", …) child binding subscribed to filtered re-runs and replaces only that slot's DOM.
  4. 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:

  1. area's binding re-runs (subscribed to width).
  2. area's signal updates → label's binding re-runs (subscribed to area).
  3. The <p> attribute and text child bindings re-run (subscribed to label).

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 mount

Function 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:

ElsewhereChroma
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.

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:

LayerImplementation
SourcehookState per filter field + finance.transactions getter
DerivedfilteredTransactions → listState (chained useMemo, getter deps)
ViewFor({ each: () => listState().groups }) inside a Show gate
EffectsuseLayoutEffect 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_signal
  • Chroma/src/action/hooks/hook_memo.rs — declared-deps and auto-track memos
  • Chroma/src/reactive_updates.rs — integration: hookState → chained useMemo → 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

On this page