Derived state
Compute values from state with useMemo chains and show them with reactive function children.
Chroma has no separate computed or watch API. Derived state is useMemo chains, and views are function children or function props. When a source signal changes, bindings re-run synchronously, so a read after a write is never stale.
Three layers
| Layer | Primitive | Role |
|---|---|---|
| Source | hookState, useReducer | Inputs the user or a store writes to |
| Derived | useMemo | Cached computation; returns a getter |
| View | Function child or function prop | Re-runs when its getters change |
// src/components/ItemList.chr
import { hookState, useMemo, e, For } from "../../pkg/chroma.js";
export function ItemList() {
const [query, setQuery] = hookState("");
const [items] = 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",
}),
e("ul", null, e(For, { each: () => filtered(), children: (item) => e("li", null, item.label) })),
);
}setQuery("gro") writes the query signal. The memo re-runs and writes a new array to its own signal. For is subscribed to filtered, so it rebuilds the list. The <input> is untouched.
Chained memos
List the upstream memo's getter in the deps array:
const [width, setWidth] = hookState(100);
const [height] = hookState(50);
const area = useMemo(() => width() * height(), [width, height]);
const label = useMemo(() => `Area: ${area()} px`, [area]);
return e("p", { "data-area": label }, label);setWidth(200) re-runs area, which updates its signal, which re-runs label, which updates the attribute and text bindings. Only subscribers of changed signals run.
Getter or value
e("p", null, count) // reactive
e("p", null, () => count() * 2) // reactive, derived inline
e("p", null, doubled) // reactive: a useMemo getter
e("p", null, doubled()) // frozen: evaluated onceFunction props follow the same rule, for example class: () => "theme-" + theme(). The rule is explained in Reactivity.
Side effects on derived data
Use an effect when derived state should trigger imperative work, and pass getters as deps:
const rows = useMemo(() => buildRows(filtered(), page()), [filtered, page]);
useLayoutEffect(() => {
animateRows(listRef.current);
}, [rows]);Equivalents to other libraries
| Elsewhere | Chroma |
|---|---|
computed(() => ...) | useMemo(() => ..., [deps]) or an inline () => ... child |
watch(source, fn) | useEffect(fn, [source]) |
effect(() => ...) (auto-track) | useEffect(fn) with deps omitted |
Show and derived lists
Show evaluates its children once. A derived list read inside Show children is therefore a snapshot, even though the memo keeps updating. Gate with Show and render the list with For and a getter each:
// src/components/FilteredList.chr
import { e, Show, For, useMemo } from "../../pkg/chroma.js";
export function FilteredList(props) {
const visible = useMemo(
() => props.todos().filter((t) => props.matches(t)),
[props.todos],
);
return e(
Show,
{ when: () => visible().length > 0, fallback: () => e("p", null, "Nothing to show") },
() => e("ul", null, e(For, { each: () => visible(), children: (t) => e("li", null, t.text) })),
);
}The failing version and its explanation are in Pitfalls.