Canonical Patterns
A situation-keyed catalogue of recommended patterns. Sister page to Common Mistakes: that page answers “why isn’t this working?” symptom-first; this page answers “what’s the right way to do this?” situation-first.
Each entry is one recommended pattern with a short code block, a one-sentence “why,” and a cross-link. For deeper decision-tables across multiple primitives, see Choosing Patterns.
Two-way bind a checkbox in a list
Section titled “Two-way bind a checkbox in a list”Recommended pattern.
each(() => todos.value, (todo) => input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }), { key: (t) => t.id },)Why. bindField handles the keyed-each() accessor, writes immutably, and identifies the right item via keyBy (default t => t.id).
See also. /api/bindfield/, Nested Item Editing → Pattern 1.
Read a per-item field reactively inside keyed each()
Section titled “Read a per-item field reactively inside keyed each()”Recommended pattern.
each(() => todos.value, (todo) => li({ class: () => todo.value.done ? "done" : "" }, () => todo.value.text), { key: (t) => t.id },)Why. In alpha.8+, the render callback’s todo is ItemAccessor<T> — read via todo.value.<field>. Wrap reactive reads in a getter (() => todo.value.text) so the reconciler keeps them fresh across entry swaps. The key: callback receives the value.
See also. /api/each/, Common Mistakes → Keyed each() mistakes.
Persist plain-object state to localStorage
Section titled “Persist plain-object state to localStorage”Recommended pattern.
import { persistedSignal } from "@whisq/core/persistence";
interface Todo { id: string; text: string; done: boolean }export const todos = persistedSignal<Todo[]>("todos", []);Why. The helper handles SSR, quotas, schema validation, and JSON round-tripping. Use at module scope in stores/.
See also. /api/persistedsignal/, Persistence Recipes.
Two-way bind a deeply-nested object field
Section titled “Two-way bind a deeply-nested object field”Recommended pattern.
import { bindPath } from "@whisq/core/forms";
form( input({ ...bindPath(user, ["profile", "name"]) }), input({ type: "email", ...bindPath(user, ["profile", "email"]) }),)Why. bindPath walks deep paths on a single record signal — the third primitive alongside bind (single signal) and bindField (array field).
See also. /api/bindpath/, Forms guide → binding into nested records.
Conditionally render with 3+ branches
Section titled “Conditionally render with 3+ branches”Recommended pattern.
match( [() => users.loading(), () => p("Loading…")], [() => !!users.error(), () => p(() => `Error: ${users.error()!.message}`)], [() => !!users.data(), () => ul(each(() => users.data()!, (u) => li(() => u.value.name)))], () => p("No data yet."), // fallback)Why. match() is first-true-wins with a trailing fallback — reach for it at 3+ branches where chained when()s would become unreadable.
See also. /api/match/, Choosing Patterns → Conditional rendering.
Toggle a CSS class based on a signal
Section titled “Toggle a CSS class based on a signal”Recommended pattern (since alpha.8).
div({ class: ["btn", () => loading.value && "btn-loading", () => active.value && "active"],})Why. The array form on class: accepts strings, falsy shorthands, and reactive getters in one prop — no separate cx / rcx helper. If any element is a function, the whole array is reactive.
See also. /api/elements/#class-array-form-since-alpha8, Choosing Patterns → Reactive class composition.
Flatten a pre-computed class list into class:
Section titled “Flatten a pre-computed class list into class:”Recommended pattern.
const base = ["btn", size === "lg" && "btn-lg"];const extras = isPrimary ? ["btn-primary", "btn-shadow"] : [];div({ class: [...base, ...extras, () => loading.value && "btn-loading"] });Why. class: arrays are not flattened recursively — a nested array like class: ["btn", ["primary", "lg"]] silently drops the inner array. Spread the parts at the call site instead. A function that returns an array has the same trap and produces a comma-joined string; return a space-joined string or spread into separate entries. Full rules: /api/elements/#rules.
See also. /api/elements/#class-array-form-since-alpha8.
Compose static class names outside an element prop
Section titled “Compose static class names outside an element prop”Recommended pattern.
const cardClass = cx("card", isPrimary && "card-primary", { active: true });Why. cx() is the right tool when you need to compose a class string as a utility return value — but on an element prop, prefer the array form above.
See also. /api/cx/.
Set up theme tokens
Section titled “Set up theme tokens”Recommended pattern.
import { theme } from "@whisq/core";
theme({ color: { primary: "#4386FB", text: "#111827", bg: "#ffffff" }, space: { sm: "0.5rem", md: "1rem", lg: "1.5rem" },});Why. Call once, at module scope, in src/styles.ts. Import that file transitively from App.ts. Duplicate calls replace the previous tokens (last-call-wins — useful for theme-switching). SSR-safe since alpha.8.
See also. /api/theme/, Styling guide → Lifecycle.
Pass a child component an accessor for cross-boundary reactivity
Section titled “Pass a child component an accessor for cross-boundary reactivity”Recommended pattern.
import type { ItemAccessor } from "@whisq/core";
type TodoItemProps = { todo: ItemAccessor<Todo>; onRemove: (id: string) => void };
const TodoItem = component((props: TodoItemProps) => li( input({ type: "checkbox", ...bindField(todos, props.todo, "done", { as: "checkbox" }) }), () => props.todo.value.text, button({ onclick: () => props.onRemove(props.todo.value.id) }, "×"), ),);
each(() => todos.value, (todo) => TodoItem({ todo, onRemove: removeTodo }), { key: (t) => t.id })Why. Pass the accessor reference itself (not props.todo.value) so the child reads current values across reconciler swaps. Type with ItemAccessor<T> so helpers typed as () => T still accept it.
See also. /api/each/#splitting-into-a-component, Nested Item Editing → Pattern 3.
Update an array signal immutably
Section titled “Update an array signal immutably”Recommended pattern.
todos.value = [...todos.value, { id: randomId(), text, done: false }];// or:todos.value = todos.value.filter(t => !t.done);// or:todos.value = todos.value.map(t => t.id === id ? { ...t, done: !t.done } : t);Why. Reactivity triggers on the signal’s .value being reassigned, not on the array being mutated in place. push() / splice() / direct index writes don’t fire subscribers.
See also. Common Mistakes → Array mutation instead of reassignment.
Generate a row id
Section titled “Generate a row id”Recommended pattern (since alpha.8).
import { randomId } from "@whisq/core/ids";
const newTodo = { id: randomId(), text, done: false };Why. UUID-v4 shape; native crypto.randomUUID() when available, Math.random fallback for older targets. Suitable for UI keys — not for security tokens.
See also. /api/randomid/.
Split a list by predicate
Section titled “Split a list by predicate”Recommended pattern (since alpha.8).
import { partition } from "@whisq/core/collections";
const [pending, done] = partition(() => todos.value, (t) => !t.done);p(() => `${pending.value.length} left`);button({ onclick: () => todos.value = pending.value }, "Clear completed");Why. One call produces two independently-subscribable ReadonlySignal<T[]> sides — subscribing to pending doesn’t churn when only done changes.
See also. /api/partition/.
See also
Section titled “See also”- Common Mistakes — sister page: symptom-first debugging.
- Choosing Patterns — decision tables for which primitive to reach for.
- Reactive shapes cheat sheet — the four-shape taxonomy for where signals go.
Docs current to v0.1.0-alpha.9 . All releases →