Skip to content
Whisq v0.1.0-alpha.9

Nested Item Editing

You have a list of items and want to edit one field on one row — toggle a checkbox, change a number, edit text. There are three idiomatic shapes for this. Pick by your item shape (plain object vs. nested signal) and per-item churn (how often a single field changes).

Each shape is already documented in depth on its canonical API page. This guide is the decision matrix that points you at the right one.

PatternItem shapeReach for it whenCanonical reference
1 · Plain immutable update via bindField{ id, text, done: boolean } — plain JSON valuesThe default. JSON-serializable items, simple stores, persistence is straightforward./api/bindfield/
2 · Nested signal per item via bind{ id, text, done: Signal<boolean> } — fields are signalsPer-item field changes are frequent and you want the array signal to stay quiet on toggle (so dependent computed / effect don’t re-run on every keystroke)./examples/todo-app/#the-store
3 · Extracted child component with accessor propEither of the above, but the per-row render is more than ~12 linesThe render is large enough to deserve its own file, or the same row markup is reused in two places. Composes with patterns 1 and 2 — pick one of those for the binding shape, then move it inside the component./api/each/#splitting-into-a-component

The three shapes compose. A real app frequently uses pattern 3 with pattern 1 inside (CRUD grid: plain JSON items, child component owns the per-row markup) — or pattern 3 with pattern 2 inside (todo-app: nested signals for narrow reactivity, child component to keep TodoApp.ts short).

Pattern 1 — Plain immutable update via bindField

Section titled “Pattern 1 — Plain immutable update via bindField”

Item shape: plain JSON. bindField mutates the array immutably (source.value = source.value.map(...)) and identifies the item via keyBy (defaults to t => t.id).

import { signal, each, ul, li, input, bindField } from "@whisq/core";
interface Todo { id: number; text: string; done: boolean }
const todos = signal<Todo[]>([
{ id: 1, text: "Learn Whisq", done: false },
{ id: 2, text: "Build an app", done: false },
]);
ul(
each(() => todos.value, (todo) =>
li(
input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }),
todo.value.text,
),
{ key: (t) => t.id },
),
)

bindField’s second argument is typed as () => T — the hybrid accessor todo (an ItemAccessor<Todo>) is structurally compatible, so this works unchanged across alpha.7 → alpha.8.

Cost: every toggle creates a new array (cheap for hundreds of items, measurable for tens of thousands). Downstream computed / effect that depend on todos.value re-run on every change. Fine for most apps.

See also: the full discriminator table (text / number / checkbox / radio), keyBy for non-id identifiers, and per-item radio groups on /api/bindfield/.

Pattern 2 — Nested signal per item via bind

Section titled “Pattern 2 — Nested signal per item via bind”

Item shape: each item holds its own per-field Signal. bind() consumes a signal reference, so toggling one checkbox flips just that signal — the array signal stays still, and dependent computeds only re-run when they read the changed field.

import { signal, each, ul, li, input, bind, type Signal } from "@whisq/core";
interface Todo { id: number; text: string; done: Signal<boolean> }
const todos = signal<Todo[]>([
{ id: 1, text: "Learn Whisq", done: signal(false) },
{ id: 2, text: "Build an app", done: signal(false) },
]);
ul(
each(() => todos.value, (todo) =>
li(
input({ type: "checkbox", ...bind(todo.value.done, { as: "checkbox" }) }),
todo.value.text,
),
{ key: (t) => t.id },
),
)

Cost: items aren’t trivially JSON.stringify-able — persistence needs a hand-rolled serialize / deserialize pair, or persistedSignal with custom options. The todo-app example shows the inline shape (/examples/todo-app/).

Why this shape exists: the array signal doesn’t churn on a single-field edit. If you have a derived computed(() => todos.value.length) and many other readers of todos.value itself, this keeps them quiet on per-row toggles. Worth the persistence overhead when per-item edits are frequent.

Pattern 3 — Extracted child component with accessor prop

Section titled “Pattern 3 — Extracted child component with accessor prop”

Same binding shapes as patterns 1 or 2, but the per-row render lives in its own component. The reconciler’s accessor passes through the prop boundary as a function reference — call it inside the child the same way as inline.

src/components/TodoItem.ts
import { component, li, span, input, button, bind, rcx } from "@whisq/core";
import type { Signal, ItemAccessor } from "@whisq/core";
type Todo = { id: number; text: string; done: Signal<boolean> };
type TodoItemProps = {
todo: ItemAccessor<Todo>; // hybrid accessor — read via .value
onRemove: (id: number) => void;
};
export const TodoItem = component((props: TodoItemProps) =>
li(
input({ type: "checkbox", ...bind(props.todo.value.done, { as: "checkbox" }) }),
span(
{ class: rcx(() => props.todo.value.done.value && "done") },
props.todo.value.text,
),
button({ onclick: () => props.onRemove(props.todo.value.id) }, "×"),
),
);

Call site:

each(() => todos.value, (todo) => TodoItem({ todo, onRemove: removeTodo }), { key: (t) => t.id })

Swap bind(props.todo.value.done, { as: "checkbox" }) for bindField(todos, props.todo, "done", { as: "checkbox" }) if your store holds plain booleans (pattern 1 inside pattern 3) — same row component, different binding shape. (bindField accepts the accessor itself, not .value — its second argument is typed as () => T and the hybrid accessor is structurally compatible.)

Cost: one extra file per row type. Pays back when the per-row markup grows past ~12 lines, when the same row is reused in two places, or when the row owns local state of its own.

Footgun to avoid: destructure the prop, never the accessor result. ({ todo, onRemove }) works because todo is the accessor reference. const { done } = props.todo.value (or const { done } = props.todo()) captures one entry’s done at setup time and doesn’t update when the reconciler swaps the entry.

See also: the full pattern, the four-archetype rule for todo() reads, and the same-key swap edge case on /api/each/#splitting-into-a-component.

The three patterns trade off the same three things differently:

  • Reactive granularity — does the array signal churn on every per-row edit (patterns 1 & 3-with-1) or stay still (patterns 2 & 3-with-2)?
  • Persistence ergonomics — plain JSON shapes round-trip through JSON.stringify for free (patterns 1 & 3-with-1); nested-signal shapes need a custom round-trip (patterns 2 & 3-with-2).
  • File-level structure — inline render keeps related code together (patterns 1 & 2); extracted component pays one extra import for reusability and shorter parent files (pattern 3).

If you only learn one: default to pattern 1. Reach for pattern 2 when you measure churn from per-row edits hurting other parts of the app. Reach for pattern 3 when the per-row render outgrows its inline home.

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