Common Mistakes
Symptom-first debugging reference. If you’ve hit a runtime error, a silent UI-doesn’t-update bug, or a TypeScript complaint that doesn’t match your mental model, scan this page for the symptom.
Sister page: Canonical Patterns — the positive complement, answering “what’s the right way to do this?” situation-first.
Most entries are short — a one-line diagnosis plus a minimal before/after. A few of the dev-mode-error entries cover multiple sub-cases the framework’s own Hint: text distinguishes; treat those as a checklist rather than a single rule.
About WhisqStructureError
Section titled “About WhisqStructureError”Most structural mistakes (bad children, malformed each() items, malformed match() arguments) throw a WhisqStructureError in dev mode. The message has a consistent shape:
<element>: expected <X>, received <Y>. Hint: <how to fix>The throw sites are wrapped in process.env.NODE_ENV !== "production" guards, so production bundles strip the validation entirely — zero size cost on shipped code, helpful messages while you’re building.
Catch them in dev to render friendly error UI:
import { errorBoundary, WhisqStructureError } from "@whisq/core";
errorBoundary( (err) => err instanceof WhisqStructureError ? div({ class: "dev-error" }, p(err.message), p({ class: "hint" }, err.hint)) : div({ class: "error" }, "Something went wrong"), () => MyApp({}),)Each entry below names the exact WhisqStructureError text where applicable, so symptom-search by the error string lands on the right diagnosis.
Child renders nothing / dev throws WhisqStructureError on unrecognized children
Section titled “Child renders nothing / dev throws WhisqStructureError on unrecognized children”Symptom. In dev, a WhisqStructureError thrown by the parent element:
<tagname>: expected WhisqNode | string | number | boolean | null | undefined | function | array, received plain object. Hint: Plain objects can't be rendered as children. Did you forget to call a component (`MyComponent({})` not `MyComponent`), or wrap a signal read in `() => signal.value`?In production builds (validation stripped) the symptom regresses to a silent drop — the position where the child should render is empty with no error.
Why. Element functions handle a specific set of child types: strings, numbers, WhisqNodes, arrays of those, getter functions returning those, and when() / match() / each() results. Plain objects, raw Promises, component definitions (not invocations), and other un-handled shapes are not renderable.
Fix. Pass a handled shape — usually invoke a component (MyComponent({}) not MyComponent), wrap a signal read in a getter (() => signal.value), or use resource() for async.
// ❌ component definition, not invocationdiv(MyComponent)
// ✅ invoke the componentdiv(MyComponent({}))
// ❌ raw signal read at setup — captures once, never updatesdiv(name.value)
// ✅ getter — re-evaluates on changediv(() => name.value)
// ❌ raw promisediv(fetch("/api/data"))
// ✅ resource() for asyncconst data = resource(() => fetch("/api/data").then((r) => r.json()));div(() => data.loading() ? "Loading…" : data.data()?.name)The Hint: line in the error message names the specific cause — read it before reaching for the docs.
Signal read without a getter in a reactive position
Section titled “Signal read without a getter in a reactive position”Symptom. Value renders once at mount, then never updates when the signal changes.
Why. Reactive props and children need a function so Whisq knows to re-evaluate them. A bare .value read is evaluated once at setup time — the reactive graph never sees the dependency.
Fix. Wrap the read in () => ....
// ❌ evaluated once at setup — never updatesdiv({ hidden: visible.value }, `Count: ${count.value}`)
// ✅ getter re-runs on every changediv({ hidden: () => visible.value }, () => `Count: ${count.value}`)See the Reactive shapes cheat sheet for which positions need getters.
Array mutation instead of reassignment
Section titled “Array mutation instead of reassignment”Symptom. Calling .push(), .splice(), etc. on a signal’s array value — no re-render.
Why. Signals use Object.is equality. Mutating the array doesn’t change its reference, so the signal doesn’t detect a change and subscribers don’t re-run.
Fix. Assign a new array.
// ❌ mutation — reference unchanged, no updateitems.value.push(newItem);
// ✅ new array — reference changes, subscribers fireitems.value = [...items.value, newItem];items.value = items.value.filter((i) => i.id !== id);items.value = items.value.map((i) => i.id === id ? { ...i, done: true } : i);.current instead of .value on a ref
Section titled “.current instead of .value on a ref”Symptom. inputEl.current is undefined. TypeScript may or may not catch it depending on config.
Why. Whisq refs are plain signals — not React-shaped. The populated element lives on .value, not .current. The ElementRef<T> type alias (from @whisq/core) is structurally Signal<T | null>.
Fix. Use .value.
// ❌ React prioronMount(() => inputEl.current?.focus());
// ✅ Whisq refs are signalsonMount(() => inputEl.value?.focus());See /api/ref/ for the full pattern including the ElementRef<T> alias.
Keyed each() mistakes
Section titled “Keyed each() mistakes”When you pass { key } to each(), the render callback’s item parameter is an ItemAccessor<T> (since alpha.8) — callable (item()), signal-shaped (item.value), and with .peek(). The same shape that’s friction-prone enough to deserve its own collected section. Five entries below cover the patterns reviewers and AI tools have hit most often.
Non-keyed each() (no { key }) passes plain values — the accessor shape only applies in keyed mode.
Reading item.field directly (without .value or call)
Section titled “Reading item.field directly (without .value or call)”Symptom. TypeScript error: Property 'field' does not exist on type 'ItemAccessor<T>'. Or — silent stale-data bug if you cast around it.
Why. ItemAccessor<T> is the accessor object; you have to read through .value (alpha.8 canonical) or call it (() legacy form) to get the entry.
Fix.
// ❌ accessing .text on the accessoreach(() => todos.value, (todo) => li(todo.text), { key: (t) => t.id })
// ✅ read via .value (alpha.8 canonical)each(() => todos.value, (todo) => li(todo.value.text), { key: (t) => t.id })
// ✅ call form (still works pre/post-alpha.8)each(() => todos.value, (todo) => li(todo().text), { key: (t) => t.id })Destructuring the accessor’s result at setup
Section titled “Destructuring the accessor’s result at setup”Symptom. First render is fine; after the source array is replaced (sort, filter, item swap), the destructured locals stay frozen at their initial value.
Why. Destructuring captures the read at setup time — const { text, done } = todo.value is a one-shot snapshot, not a live reference. The reconciler swaps the entry behind the same key, but your destructured locals don’t see it.
Fix. Read through the accessor inside reactive positions, not at setup.
// ❌ destructuring at setup — captures initial entry, never updateseach(() => todos.value, (todo) => { const { text, done } = todo.value; // frozen! return li({ class: done && "done" }, text);}, { key: (t) => t.id })
// ✅ read inside reactive positionseach(() => todos.value, (todo) => li({ class: () => todo.value.done && "done" }, () => todo.value.text), { key: (t) => t.id })Passing the accessor’s value (snapshot) into a child component
Section titled “Passing the accessor’s value (snapshot) into a child component”Symptom. Child renders the right item once, then never reflects updates when the reconciler swaps the entry.
Why. Passing todo.value (or todo()) into a component snapshots the current entry. The child receives a frozen T, not a live accessor — same root cause as the destructuring case, just across a component boundary.
Fix. Pass the accessor itself, type the prop as ItemAccessor<T>, and read through it inside the child.
// ❌ passes a frozen snapshot — child stops updating after a swapeach(() => todos.value, (todo) => TodoItem({ todo: todo.value, onRemove: removeTodo }), { key: (t) => t.id })
// ✅ pass the accessor; declare the prop as ItemAccessor<Todo>import type { ItemAccessor } from "@whisq/core";type TodoItemProps = { todo: ItemAccessor<Todo>; onRemove: (id: string) => void };const TodoItem = component((props: TodoItemProps) => li({ onclick: () => props.onRemove(props.todo.value.id) }, () => props.todo.value.text));
each(() => todos.value, (todo) => TodoItem({ todo, onRemove: removeTodo }), { key: (t) => t.id })See /api/each/#splitting-into-a-component for the full child-component pattern.
Inline .map() where keyed each() is correct
Section titled “Inline .map() where keyed each() is correct”Symptom. Re-rendering a list — even on a single-item edit — recreates every <li> from scratch. Focus loss, scroll jump, animation flicker, slow updates on long lists.
Why. () => arr.value.map(t => li(t.text)) re-runs the whole getter on any change to arr.value, throwing away every existing DOM node. each(() => arr.value, render, { key }) does LIS-based diffing — only changed nodes move, insert, or remove.
Fix. Use each({ key }) whenever items have stable identities.
// ❌ inline map — full re-render on every changeul(() => todos.value.map((t) => li(t.text)))
// ✅ keyed each — DOM is reused across reorders/editsul(each(() => todos.value, (t) => li(() => t.value.text), { key: (t) => t.id })).value flagged as unknown by TypeScript
Section titled “.value flagged as unknown by TypeScript”Symptom. TypeScript error like Property 'value' does not exist on type 'never' or Property 'value' does not exist on type 'Function' when reading todo.value.<field>.
Why. The hybrid accessor shape (since alpha.8) is ItemAccessor<T>, which is callable AND has .value. TypeScript only sees both shapes when T infers correctly — typically because the source signal is typed.
Fix. Type the source signal explicitly. The render callback’s todo will then infer as ItemAccessor<Todo>.
// ❌ untyped — TS may infer todos.value as `any[]` and lose the shapeconst todos = signal([{ id: "1", text: "Learn", done: false }]);each(() => todos.value, (todo) => li(todo.value.text), { key: (t) => t.id })// ^^^^^^^^^^^ may not type-check
// ✅ typed source — todo infers as ItemAccessor<Todo>interface Todo { id: string; text: string; done: boolean }const todos = signal<Todo[]>([{ id: "1", text: "Learn", done: false }]);each(() => todos.value, (todo) => li(todo.value.text), { key: (t) => t.id })See /api/each/ for the full ItemAccessor<T> interface and the four-archetype rule on when to use .value (snapshot) vs a getter (() => todo.value.field — re-evaluates) vs .peek() (untracked read).
Hoisting .value.field out of a reactive getter
Section titled “Hoisting .value.field out of a reactive getter”Symptom. A class / style / text that depended on a signal field stops updating after the first render. No dev error — the read just captures the snapshot at setup time, and subsequent signal.value = writes appear to have no effect in that component.
Why. .value looks like a plain property read, so the natural refactor for readability — “extract the expression into a local, use the local in the getter” — captures the value at setup time. The () => … wrapper runs once and closes over the frozen local instead of re-reading the signal on every evaluation. The signal drives its own reactivity, but only if the getter’s body actually reads .value each time it runs.
Fix. Read .value (or .value.field) inside the getter, not outside it. If you need a local for readability, make it an accessor function (() => signal.value.field) and call it inside the getter.
// ❌ stale — captured at setup time, frozen after first renderconst done = props.todo.value.done;li({ class: [s.item, () => done && s.itemDone] }, props.todo.value.text);
// ✅ reads on every getter invocationli({ class: [s.item, () => props.todo.value.done && s.itemDone] }, () => props.todo.value.text);
// ✅ if you want a readable local, make it an accessor (still re-reads)const done = () => props.todo.value.done;li({ class: [s.item, () => done() && s.itemDone] }, () => props.todo.value.text);The trap generalises beyond keyed each() accessors. Plain signals have the same shape:
const user = signal({ name: "Alice", role: "admin" });
// ❌ stale — `role` captures "admin" at setup; later writes don't update the classconst role = user.value.role;div({ class: [s.card, () => role === "admin" && s.adminCard] }, "…");
// ✅ keep the .value.field read inside the getterdiv({ class: [s.card, () => user.value.role === "admin" && s.adminCard] }, "…");Rule: never hoist .value.field (or .value, or accessor.value.field) out of a reactive position. You’ve captured the value, not the reactivity. If you want the local, make it a getter function.
See also: /api/each/ → Reactive fields inside keyed each(), Reactive shapes cheat sheet.
bind(signal.value) instead of bind(signal)
Section titled “bind(signal.value) instead of bind(signal)”Symptom. TypeError: bind() expects a Signal as its first argument. Or — the binding doesn’t write back: typing into the input doesn’t update the signal.
Why. bind() needs the signal reference, not the current value. Passing .value unwraps the signal into a plain value, which bind() can’t subscribe to.
Fix. Pass the signal itself.
// ❌ passing the unwrapped valueinput({ ...bind(name.value) })
// ✅ pass the signal referenceinput({ ...bind(name) })
// ✅ inside keyed each, call the accessor then pass the signal field// (bind receives the stable Signal<boolean>, not its value)input({ type: "checkbox", ...bind(todo().done, { as: "checkbox" }) })bind() spread followed by a handler that silently drops it
Section titled “bind() spread followed by a handler that silently drops it”Symptom. You added an oninput / onchange next to a bind() (or bindField / bindPath) spread to track user input, and the signal stopped updating. Dev since alpha.9 also prints a console.warn naming the overwritten handler.
Why. Object spread copies keys left-to-right; the later handler wins. bind() returns { value: ..., oninput: ... } — if your spread-order reads { ...bind(draft), oninput: (e) => track(e) }, your oninput overwrites bind’s and the binding stops writing back.
Fix. Spread bind() last so your own handler lives in the props before the spread; if there’s still a collision, it’s bind’s handler that wins (visibly), not yours — silent drops never happen on your code:
// ❌ silent drop pre-alpha.9; warns in alpha.9+ devinput({ ...bind(draft), oninput: (e) => track(e),});
// ✅ spread bind last — bind wins, user handler has to be composed explicitlyinput({ oninput: (e) => track(e), ...bind(draft),});
// ✅ or compose transparently: read bind() into a local then chainconst draftBinding = bind(draft);input({ ...draftBinding, oninput: (e) => { draftBinding.oninput(e); // run bind's handler first track(e); },});Detection caveat. The framework’s warning only catches direction 1 — spread-first, user-handler-second. If you write the other direction (user handler first, then ...bind(sig)), your handler is destroyed without a trace — the warning can’t fire because the evidence is gone by the time the element builder looks. That’s exactly why the convention is “spread bind last” — it makes direction 2 the safe shape.
See /api/bind/#handler-collision-dev-warning-since-alpha9 for the framework-side mechanism.
Destructuring props at component setup
Section titled “Destructuring props at component setup”Symptom. Props from a parent update, but the child never reflects the change.
Why. component() setup runs once. Destructuring at the signature (({ value })) copies the prop’s value at setup time — you lose the live reference. If the parent passed a getter prop, destructuring its result likewise captures a snapshot.
Fix. Access props.value inside the render body — or, if the prop is a getter, call it at read time.
// ❌ destructured at setup — captures the initial value foreverconst Display = component(({ value }: { value: number }) => p(`Count: ${value}`) // never updates);
// ✅ read through props on every re-evaluationconst Display = component((props: { value: () => number }) => p(() => `Count: ${props.value()}`));The footgun is destructuring the result of calling a getter — const { value } = props.getter() captures a snapshot that never updates. Destructuring the getter function itself (({ getter }: { getter: () => number }) => ...) is safe because you’re copying the function reference, but props.getter() at the call site is the clearer pattern and the one the docs recommend consistently.
match() given an object instead of tuples
Section titled “match() given an object instead of tuples”Symptom. In dev, a WhisqStructureError thrown at the match() call site:
match: expected a branch tuple `[() => boolean, () => WhisqNode]`, received plain object. Hint: `match()` is a predicate chain, not a value-dispatch table. Plain objects like `{ loading: ..., error: ... }` aren't accepted — write tuples: `[() => state.value === 'loading', () => ...]`.Why. match() is a predicate chain — variadic [predicate, render] tuples evaluated top-to-bottom, first-truthy wins. It is not pattern matching or value dispatch. The validator catches object literals at the boundary so you don’t fall through to a confusing TypeError (the alpha.6 symptom) or silent fallthrough (the worst case).
Fix. Convert each key: value entry to a [predicate, render] tuple.
// ❌ object form — WhisqStructureError in devmatch({ loading: () => p("Loading…"), error: () => p("Failed"), data: () => ul(/* … */),});
// ✅ tuplesmatch( [() => state.value === "loading", () => p("Loading…")], [() => state.value === "error", () => p("Failed")], [() => state.value === "data", () => ul(/* … */)],);If your branches all dispatch on the same signal’s value and writing the predicates is tedious, a switch inside a getter child is the better fit — see What match() isn’t for when each form applies.
match() at a component’s root (pre-alpha.9 only)
Section titled “match() at a component’s root (pre-alpha.9 only)”Symptom (pre-alpha.9 only). A component whose entire job was to branch wouldn’t mount — WhisqStructureError thrown at first render, or a TypeScript complaint that the component() setup return type didn’t match WhisqNode.
Status. Fixed in alpha.9 (release-note e6d59c4). component()’s setup return type was widened from WhisqNode to WhisqNode | (() => unknown), so component(() => match(...)) works directly — no wrapper element needed. See /api/component/#function-child-root-since-alpha9 for the canonical new shape.
// alpha.9+ — function-child root is the component root, no wrapper neededconst StatusView = component(() => match( [() => loading.value, () => Spinner({})], [() => !!error.value, () => ErrorPanel({ err: error.value })], () => DataView({}), ),);If you’re on alpha.8 or earlier, the fix is still to wrap in a neutral element:
// pre-alpha.9 workaround — wrap in an elementconst StatusView = component(() => div( match( [() => loading.value, () => Spinner({})], [() => !!error.value, () => ErrorPanel({ err: error.value })], () => DataView({}), ), ),);The wrapping-element shape remains valid in alpha.9+ too — reach for it when the root needs class / style / events attached. See Using match() as a component root for the decision.
bindField write throws WhisqKeyByError on a removed item
Section titled “bindField write throws WhisqKeyByError on a removed item”Symptom. In dev (since alpha.8), a WhisqKeyByError thrown after the user clicks a checkbox or edits a field on a row that was just removed from the source array — or whose keyBy returns a different value at write time than at read time:
WhisqKeyByError: bindField: no item in source matched key … for write to "done" sourceKeys: [...] // what was actually present at write time targetKey: ... // what bindField was looking for field: "done"Why. bindField identifies which item to rewrite via keyBy (default t => t.id). If the item was removed from source between render and the user’s interaction, or keyBy returns inconsistent results (e.g. mutates the read key), the write has nothing to rewrite. Pre-alpha.8 silently warned and discarded the write; alpha.8 surfaces it as a WhisqKeyByError so the bug is visible at the first failed click instead of drowning in the console.
Fix. Two paths:
- Real bug — make sure the row removal also clears any references that hold the stale accessor (avoid retained closures pointing at the removed
todo). MakekeyBydeterministic: read the same field both ways. - Deliberate test path — pass
strict: falseon the call to suppress the throw and keep the legacy warn-and-discard behaviour:
input({ ...bindField(todos, todo, "done", { as: "checkbox", strict: false }),});See /api/bindfield/#dev-mode-behaviour-and-the-strict-option for the full strict matrix and the WhisqKeyByError field reference.
Inline signal() creation inside a reactive render
Section titled “Inline signal() creation inside a reactive render”Symptom. Local state resets to its initial value on every change. Effects seem to run too often.
Why. Creating a signal inside a getter, element callback, or each() render callback means a new signal instance is constructed each time the surrounding function re-evaluates. The old signal’s subscribers are dropped, and the new signal has no history.
Fix. Hoist signal creation to module scope or component setup.
// ❌ new signal every time the getter re-evaluatesdiv(() => { const hovered = signal(false); // reset on every re-evaluation! return span({ onmouseenter: () => hovered.value = true }, "Hello");})
// ✅ signal lives in component setup — stable across re-evaluationsconst Tooltip = component(() => { const hovered = signal(false); return div(span({ onmouseenter: () => hovered.value = true }, "Hello"));});Theme variables flipped unexpectedly after merge
Section titled “Theme variables flipped unexpectedly after merge”Symptom. CSS custom properties (--color-primary, --space-md, etc.) stopped matching the theme you set in src/styles.ts. Dev since alpha.9 prints a console.warn naming the duplicate theme() call.
Why. theme() is last-call-wins — the second call replaces the first <style> block entirely. This is intentional for theme-switching, but if a second styles.ts sneaks into the import graph (a new components/styles.ts, an accidentally-imported stylesheet from a dependency, a duplicate file after a merge), it silently wipes the original tokens.
Fix. Search for all theme( call sites:
rg "theme\(" src/Keep one call at module scope in your canonical src/styles.ts. Delete duplicates (or merge them into the canonical file). If a duplicate is intentional (a theme-switching toggle), pass { silent: true } to opt out of the warning:
// Intentional runtime theme switchconst enableDark = () => theme(darkTokens, { silent: true });Production builds strip the whole duplicate-detection guard, so this is a dev-time safety net with zero shipped cost. See /api/theme/#duplicate-call-warning-since-alpha9 for the framework-side mechanism.
See also
Section titled “See also”- Reactive shapes cheat sheet — four positions × which shape applies.
- Getters and accessors — glossary definitions.
/api/each/#reactive-fields-inside-keyed-each— the position-based rule for snapshot vs getter in lists./core-concepts/signals/— how reactivity tracks and re-runs.
Docs current to v0.1.0-alpha.9 . All releases →