Skip to content

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

// 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
}
ParamTypeDescription
items() => T[]Reactive array of items
render (non-keyed)(item: T, index: number) => WhisqNodeReceives plain values; called on every source change
render (keyed)(item: ItemAccessor<T>, index: ItemAccessor<number>) => WhisqNodeReceives hybrid accessors — read via item.value (canonical), item(), or item.peek()
options.key(item: T) => unknownKey function. Presence switches to the keyed overload. Receives the value, not an accessor.
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 () => TbindField(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.

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> }:

PositionShape (alpha.8 canonical)Why
Static text childtodo.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 signalbind(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 handleronchange: () => 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.

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:

src/components/TodoItem.ts
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 },
),
)
  • The accessor is a hybrid object. Passing todo into TodoItem({ todo }) copies the reference. Inside the component, props.todo.value (or props.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.value captures 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.

  • when(), match() — pair with each() for empty-state messaging around a list.
  • bindField() — two-way bind a field on the items each() 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 →