Skip to content
Whisq v0.1.0-alpha.9

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.

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.

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.

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.

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

Recommended pattern.

src/styles.ts
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.

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.

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

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

Docs current to v0.1.0-alpha.9 . All releases →