Common Pitfalls
Anti-patterns and runtime errors from real Chroma apps — dependency arrays, re-entrant writes, TDZ, derived-state guards, and animation timing.
These patterns caused production bugs in Targ-Apps-Finance. They are Chroma framework mistakes, not Finance-specific logic. Fix them at the source before they spread to other apps.
Chroma fails loudly on several of these — you will see explicit runtime errors rather than silent stale UI. Treat the messages as guardrails, not annoyances.
1. Dependencies must be getters
useEffect, useLayoutEffect, and useMemo dependency arrays must list signal getters (functions), never primitive values, string literals, or the result of calling a getter.
Wrong
// ❌ values — Chroma cannot subscribe to numbers or strings
useEffect(() => { loadChart(); }, [monthCount, granularity, "transactions"]);
useMemo(() => rows(), [count()]);
useEffect(() => { sync(); }, [accountId()]);Runtime error:
chroma: dependencies must be getters — pass 'count', not 'count()'Right
// ✅ getters from hookState, store signals, or other memos
useEffect(() => { loadChart(); }, [accounts, transactions, granularity]);
useMemo(() => rows(), [count]);
useEffect(() => { sync(); }, [accountId]);When deps come from a store, pass the store's signal accessors — not their evaluated values:
const { accounts, transactions, granularity } = financeStore;
useEffect(() => {
loadChart(accounts(), granularity());
}, [accounts, transactions, granularity]);See Hooks Guide — Dependency arrays for the full subscription model.
2. Update loops and re-entrant writes
Do not call a setter inside an effect or memo body while that same binding reads a signal listed in its dependency array. Chroma evaluates bindings synchronously; a write during evaluation triggers a re-entrant update.
Wrong
// ❌ reads accountId in deps, then writes accountId on every run
useEffect(() => {
onAccountChange(accountId());
}, [accountId]);
// ❌ memo reads validation() and writes filter in the same chain
const filtered = useMemo(() => {
setFilterDefaults(validation().fields);
return items().filter(matches);
}, [items, validation]);Runtime error:
binding wrote to a signal it depends on while still evaluatingRight
Separate initialization from reactive sync, narrow dependencies, and guard writes:
// ✅ run once when the panel opens
useEffect(() => {
if (!open()) return;
onAccountChange(initialAccountId());
}, [open]);
// ✅ only write when the value actually changed
useEffect(() => {
const id = accountId();
if (id !== lastSynced()) {
lastSynced(id);
onAccountChange(id);
}
}, [accountId, lastSynced]);
// ✅ derive without side effects; write in an effect with explicit deps
const filtered = useMemo(
() => items().filter((row) => matches(row, filter())),
[items, filter],
);Prefer [] or [open] for one-shot setup; use equality checks before calling setters inside reactive bodies.
3. Temporal dead zone (TDZ) in component bodies
Components run top to bottom once. A useMemo getter or inline function must not reference const bindings declared later in the same function — JavaScript TDZ applies exactly as in ordinary functions.
Wrong
function TransactionForm() {
const formData = useMemo(
() => ({ accountId: resolvedAccountId(), amount: amount() }),
[resolvedAccountId, amount],
);
const resolvedAccountId = useMemo(
() => accountId() ?? defaultAccount(),
[accountId, defaultAccount],
);
// ReferenceError or undefined reads — resolvedAccountId is not initialized yet
}Right
Declare helpers and upstream memos before memos that depend on them:
function TransactionForm() {
const resolvedAccountId = useMemo(
() => accountId() ?? defaultAccount(),
[accountId, defaultAccount],
);
const formData = useMemo(
() => ({ accountId: resolvedAccountId(), amount: amount() }),
[resolvedAccountId, amount],
);
}Rule of thumb: order declarations like a DAG — sources first, derived memos after their inputs.
4. Guard derived and validation memos
When a memo throws or short-circuits, downstream memos may receive undefined instead of an object. Accessing properties without guards crashes the whole derived chain.
Wrong
const validation = useMemo(() => validate(formData()), [formData]);
const canSubmit = useMemo(() => validation().valid, [validation]); // 💥 if validation is undefinedRight
const validation = useMemo(() => validate(formData()), [formData]);
const canSubmit = useMemo(
() => validation()?.valid ?? false,
[validation],
);Apply the same pattern to nested fields: validation()?.errors?.amount, default objects for empty form state, and early returns inside memos when prerequisites are missing.
5. Animation hooks and invisible content
This pitfall spans Chroma apps that use Chroma-Style entry animations. It is not a Chroma core bug, but it shows up often alongside the patterns above.
[data-animate="fade-up"] (and similar hooks) set opacity to 0 in CSS until Anime.js runs. If initAnimations() executes before the target nodes are mounted — or before a reactive Show/For swap finishes — the animation never attaches and content stays invisible.
Wrong
useEffect(() => {
initAnimations(document); // runs while list is still empty / opacity 0 stuck
}, [route]);Right
useLayoutEffect(() => {
if (!open()) return;
initAnimations(panelRoot());
}, [open, panelRoot]);
// after For/Show swaps content:
useEffect(() => {
refreshAnimations(listRoot());
}, [items, listRoot]);Finance documents the full animation API in Targ-Apps-Finance — Styling. Prefer refreshAnimations after reactive DOM swaps; keep base visibility in CSS so empty states remain readable without waiting for Anime.
6. Show freezes list children — use For for derived data
useMemo and effects can be wired correctly while the visible list stays wrong. Show evaluates its children function once when the branch is first mounted (under untracked); it only toggles display when when() changes. Reading listState().groups inside that child captures a static array — memos and useLayoutEffect still update, but the DOM rows do not.
Wrong
e(Show, { when: hasItems },
() => e("ul", null,
listState().groups.map((g) => e("li", null, g.label)), // frozen at first mount
),
);Right
Use Show as a gate (empty vs content). Put the list in For with a getter each:
e(Show, { when: hasItems },
() => e("ul", null,
e(For, {
each: () => listState().groups,
children: (g) => e("li", null, g.label),
}),
),
);Production case: Targ-Apps-Finance Transactions. Full pattern: Derived State & Dynamic Updates.
Quick reference
| Symptom | Likely cause | Fix |
|---|---|---|
dependencies must be getters | [count()] or string/primitive in deps | Pass [count] |
binding wrote to a signal it depends on | Setter inside effect/memo that lists the same signal | Narrow deps, guard writes, split init |
ReferenceError / undefined in memo | Using a const declared below | Reorder declarations |
Crash on .valid / .errors | Upstream memo failed silently | Optional chaining + defaults |
| UI present in DOM but invisible | data-animate before mount | useLayoutEffect, refreshAnimations |
| Filters/memos update, list rows don't | .map inside Show children | For({ each: () => derived() }) |
Where to go next
- Hooks Guide — dependency arrays and hook semantics
- Reactivity Model — how bindings and subscriptions work
- Derived State & Dynamic Updates —
useMemochains and theShow/Forlist pitfall - Control Flow —
Show/Fortiming with effects - Targ-Apps-Finance — Styling —
data-animateandrefreshAnimations