Choosing Patterns
Each table answers one “which primitive should I reach for?” question. Each row is one paragraph at most — depth lives in the linked API pages.
For the four-shape reactive-position taxonomy (getter child / getter prop / bind() spread / bindField() spread), see the Reactive shapes cheat sheet. The tables below cover everything else.
Two-way binding — which helper?
Section titled “Two-way binding — which helper?”The default decision when you wire an input to state.
| Situation | Helper | Why |
|---|---|---|
| One signal, one input | bind(signal) | The dedicated primitive. Same discriminator (as) for text / number / checkbox / radio. |
A field of an item inside a keyed each() | bindField(source, item, key, …) | Accepts the keyed-each() accessor; produces immutable array writes; no manual event pair. |
| A field at a nested path inside a single record signal | bindPath(source, ['a', 'b'], …) (sub-path: @whisq/core/forms) | Reaches deep object paths that bind() can’t address. |
The item field is itself a Signal (per-item nested signals) | bind(item.value.field, …) | The signal drives its own reactivity; treat it as the “one signal” case. |
| Custom write logic / derived field / debouncing | Manual value/oninput (or checked/onchange) pair | Escape hatch. Reach for it only when none of the above fit. |
For the deeper “plain object vs nested signals vs extracted child component” decision, see the Nested Item Editing guide — that’s a thicker pedagogical page.
List rendering — when to split into a child component?
Section titled “List rendering — when to split into a child component?”| Situation | Reach for | Why |
|---|---|---|
| Per-row render is ≤ 12 lines, used in one place | Inline each(() => arr.value, (todo) => li(…), { key }) | Don’t pay for a file boundary you don’t need. |
| Per-row render is > 12 lines, or reused in two places | Extracted child component with todo: ItemAccessor<T> typed prop | Shorter parent file; reusable. See /api/each/#splitting-into-a-component. |
| Items have stable identities | each(…, { key: (t) => t.id }) | LIS-based diffing; preserves DOM state across reorders. |
| Items have no stable identity (computed on the fly) | Non-keyed each(…, render) | Re-creates DOM nodes on every change. Fine for short lists. |
Items are < ~20, rarely change | Inline () => arr.value.map(t => li(t)) | Cheapest shape; .map() re-renders the whole list but at this size that’s invisible. |
Persistence — which shape?
Section titled “Persistence — which shape?”Three patterns by data shape. Full code lives in Persistence Recipes.
| What you have | Reach for | Quick reason |
|---|---|---|
| Plain JSON-serializable items | persistedSignal<T[]>("key", []) | One import, no helper. Default. |
Items with nested per-field Signals (done: Signal<boolean>) | Hand-rolled loadTodos() + effect() + signal<Todo[]> | JSON.stringify can’t round-trip Signals. |
| Server-derived data with a freshness cache | resource() + persistedSignal() (initialValue: cache.value) | Server owns truth; cache warms first paint. |
| State > a few MB | Custom IndexedDB integration with signal() + effect() | LocalStorage quotas vary; reach for IndexedDB. |
Conditional rendering — when(), match(), or inline ternary?
Section titled “Conditional rendering — when(), match(), or inline ternary?”| Situation | Reach for | Why |
|---|---|---|
| One reactive condition, one branch (show/hide) | when(() => cond, () => Block) | Simplest. Falsy branch renders nothing. |
| Two distinct branches | when(() => cond, () => A, () => B) | Two-arg form is fine. Don’t escalate to match() for two cases. |
| 3+ mutually-exclusive branches (loading / error / data, etc.) | match([pred, render], …, fallback) | First-true-wins; trailing fn is the fallback. |
| A switch on a discriminator value | match([() => state.kind === 'a', …], …) | One predicate per kind. |
Inline text-vs-text ternary (a > b ? "yes" : "no") | Plain getter — () => a.value > b.value ? "yes" : "no" | when() / match() are for nodes, not values. |
Reactive class composition — array form, cx, or rcx?
Section titled “Reactive class composition — array form, cx, or rcx?”Since alpha.8, the array form on class: covers most cases. cx / rcx are escape hatches.
| Situation | Reach for | Why |
|---|---|---|
| Mix of static class + reactive toggles on an element | Array form: class: ["btn", () => loading.value && "btn-loading"] (alpha.8) | Strings, falsy shorthands, reactive getters in one prop. No separate helper. |
| Composing class names outside an element prop (e.g. returning a string from a utility) | cx("a", b && "c", { d: true }) | Static composition; returns string. |
| Composing reactive class names outside an element prop | rcx("a", () => b.value && "c") | Reactive composition; returns () => string. |
| Single class that depends on one signal | class: () => active.value ? "on" : "off" | Plain getter. Simplest. |
Sub-renderer — plain factory or component()?
Section titled “Sub-renderer — plain factory or component()?”Within a file, the micro-decision: is this tiny sub-renderer a component or just a function?
| The sub-renderer has… | Reach for | Why |
|---|---|---|
| No state, no lifecycle, ≤ ~10 lines | Plain factory const FilterBtn = (...) => button(...) | A function returning element markup is first-class. No fragment, no setup scope. |
Local signal() or any effect() | component() | A factory runs once per call site; setup scope requires component(). |
onMount / onCleanup / inject() | component() | Lifecycle hooks and context reads only fire in component() setup. |
| Reused across files (PascalCase export) | component() | Named boundaries deserve a definition, not an inline factory. |
The promotion rule: add signal() / effect() / lifecycle → promote to component(). See Components → “Is this a component or just a function?” for worked examples.
See also
Section titled “See also”- Reactive shapes cheat sheet — the four-shape “where does the signal go” taxonomy.
- Nested Item Editing — deeper decision tree for editing a field inside a keyed list.
- Persistence Recipes — full code for the three persistence shapes.
- Common Mistakes — symptom-first debugging when a pattern doesn’t behave.
Docs current to v0.1.0-alpha.9 . All releases →