each()
Render a reactive list with optional keyed DOM reconciliation. Two overloads — non-keyed (fresh nodes on every change) and keyed (LIS-based diffing with hybrid accessors for per-item reactivity — see the ItemAccessor<T> interface below).
Signature
Section titled “Signature”// Non-keyed — item is a plain T, nodes are recreated on every source change.function each<T>( items: () => T[], render: (item: T, index: number) => WhisqNode,): () => Child[];
// Keyed — item / index are HYBRID accessors (callable + signal-shaped).// DOM nodes are reused across source changes.function each<T>( items: () => T[], render: (item: ItemAccessor<T>, index: ItemAccessor<number>) => WhisqNode, options: { key: (item: T) => unknown },): WhisqNode;
// since alpha.8 — `ItemAccessor<T>` is exported from "@whisq/core"interface ItemAccessor<T> { (): T; // call form — what existed pre-alpha.8 readonly value: T; // .value form — canonical for new code peek(): T; // peek form — read without subscribing}Parameters
Section titled “Parameters”| Param | Type | Description |
|---|---|---|
items | () => T[] | Reactive array of items |
render (non-keyed) | (item: T, index: number) => WhisqNode | Receives plain values; called on every source change |
render (keyed) | (item: ItemAccessor<T>, index: ItemAccessor<number>) => WhisqNode | Receives hybrid accessors — read via item.value (canonical), item(), or item.peek() |
options.key | (item: T) => unknown | Key function. Presence switches to the keyed overload. Receives the value, not an accessor. |
Example — keyed
Section titled “Example — keyed”import { signal, each, ul, li } from "@whisq/core";
const todos = signal([ { id: 1, text: "Learn Whisq" }, { id: 2, text: "Build an app" },]);
ul( each( () => todos.value, (todo) => li(() => todo.value.text), // .value — canonical since alpha.8 { key: (t /* value, not accessor */) => t.id }, ),)The todo() call form (pre-alpha.8) still works and is structurally compatible with helpers typed as () => T — bindField(todos, todo, "done", { as: "checkbox" }) and similar accept either form unchanged. New code should prefer the .value shape for consistency with the rest of the reactive-access rule.
Reactive fields inside keyed each()
Section titled “Reactive fields inside keyed each()”The keyed callback’s item is () => T — not a plain T. When to call todo() and when to wrap it in a getter isn’t about whether the field “changes” — it’s about whether the position you’re using it in needs to re-evaluate.
Four archetypes, from a Todo = { id: string, text: string, done: Signal<boolean> }:
| Position | Shape (alpha.8 canonical) | Why |
|---|---|---|
| Static text child | todo.value.text (or legacy todo().text) | Text renders once; text is a plain string. Reading at render time gives the current item. |
bind() on a per-item signal | bind(todo.value.done, { as: "checkbox" }) | bind() receives a signal reference, and the Signal drives its own reactivity. Reading .value once captures the stable signal object. |
| Re-evaluated position reading a signal’s value | () => todo.value.done.value && s.doneText (inside rcx or a prop getter) | rcx() / prop getters re-run when dependencies change. The getter closes over todo, so on re-run it sees the current entry and reads .done.value from the stable signal. |
| Event handler | onchange: () => toggle(todo.value.id) | Fires on user interaction — re-reads the current item at click time, even if the array was reshuffled since render. |
each( () => todos.value, (todo) => li( input({ type: "checkbox", checked: () => todo.value.done.value, // reactive: getter re-runs, reads .value onchange: () => toggle(todo.value.id), // fresh read at click time }), todo.value.text, // static snapshot — plain string field ), { key: (t /* value, not accessor */) => t.id },)When bind(todo.value.done, ...) is safe: the signal object at todo.value.done has stable identity — the store creates each todo’s done signal once and never replaces it. If your store swaps signal objects under a same key (e.g. todos.value = todos.value.map(t => t.id === x ? { ...t, done: newSignal } : t)), the snapshot goes stale; use the re-evaluated shape (pass rcx/prop-getters or re-build the binding).
The legacy todo() form continues to work everywhere .value works (the accessor is structurally () => T), so existing call sites do not need to migrate. Mix-and-match within a single render is fine but discouraged for readability — pick one form per file.
Splitting into a component
Section titled “Splitting into a component”For lists past about a dozen lines of per-item render, factor the render into its own component. The accessor passes through the boundary as a prop typed as ItemAccessor<T> — read inside the component the same way:
import { component, li, span, input, button, bind, rcx } from "@whisq/core";import type { Signal, ItemAccessor } from "@whisq/core";import { s } from "../styles";
type Todo = { id: number; text: string; done: Signal<boolean> };
type TodoItemProps = { todo: ItemAccessor<Todo>; // hybrid accessor — read via .value (or call form) onRemove: (id: number) => void;};
export const TodoItem = component((props: TodoItemProps) => li({ class: s.item }, input({ type: "checkbox", ...bind(props.todo.value.done, { as: "checkbox" }) }), span( { class: rcx(() => props.todo.value.done.value && s.doneText) }, props.todo.value.text, // snapshot — safe when store never swaps item objects per key ), button({ onclick: () => props.onRemove(props.todo.value.id) }, "×"), ),);If your store does swap item objects under a same key (e.g. todos.value = todos.value.map(t => t.id === id ? { ...t, text: newText } : t)), wrap the text read in a getter — () => props.todo.value.text — so the reconciler’s per-entry signal drives a re-read. The todo-app example’s store keeps object identity per key, which is why the snapshot form is safe there.
Call site:
ul({ class: s.list }, each( () => todos.value, (todo) => TodoItem({ todo, onRemove: removeTodo }), { key: (t) => t.id }, ),)Why it works
Section titled “Why it works”- The accessor is a hybrid object. Passing
todointoTodoItem({ todo })copies the reference. Inside the component,props.todo.value(orprops.todo()) calls into the same accessor that the reconciler owns — the reconciler writes fresh values into the per-entry signal, so reads always return the current item. - Destructuring at the prop boundary is safe.
(props)or({ todo, onRemove })both work: you’re copying the accessor reference, not reading through it. The footgun is destructuring the result:const { done } = props.todo.valuecaptures at setup time and won’t update when the reconciler swaps entries. - The same four-archetype rule applies — snapshot reads (
props.todo.value.text),bind()on a stable signal (bind(props.todo.value.done, ...)), re-evaluated getters (() => props.todo.value.done.value && ...), event handlers (() => props.onRemove(props.todo.value.id)). Same shapes, just inside a component.
The pre-alpha.8 call form (props.todo: () => Todo, read as props.todo()) still works unchanged — ItemAccessor<T> is structurally assignable to () => T. Migrate at your own pace.
See the Reactive shapes cheat sheet for the full four-shape taxonomy. For the decision matrix on which per-item-editing pattern to reach for (plain immutable / nested signal / extracted child), see the Nested Item Editing guide. For the canonical wrong/why/right pairings on ItemAccessor<T> reads, see Keyed each() mistakes in /common-mistakes/ and Hoisting .value.field out of a reactive getter for the general stale-capture trap.
Related primitives
Section titled “Related primitives”when(),match()— pair witheach()for empty-state messaging around a list.bindField()— two-way bind a field on the itemseach()iterates.partition()— split the source array into two derived signals before rendering (e.g. active vs done).signalMap(),signalSet()— iterate reactive collections whose membership changes.
Docs current to v0.1.0-alpha.9 . All releases →