Skip to content

Spread bindField(source, item, key, options?) into a form control to wire it to one field of one item inside a signal-held array. Sibling to bind() — same discriminator shapes — but reaches into the per-item-per-field shape that bind() doesn’t.

The realistic case: a checkbox per row in a list of todos, where the source array holds plain objects (no nested signals).

each(() => todos.value, (todo) =>
input({
type: "checkbox",
...bindField(todos, todo, "done", { as: "checkbox" }),
}),
{ key: (t) => t.id },
)
function bindField<T, K extends keyof T>(
source: Signal<T[]>,
item: () => T,
key: K,
): TextBind;
function bindField<T, K extends keyof T>(
source: Signal<T[]>,
item: () => T,
key: K,
options: { as: "number" } & Common<T>,
): NumberBind;
function bindField<T, K extends keyof T>(
source: Signal<T[]>,
item: () => T,
key: K,
options: { as: "checkbox" } & Common<T>,
): CheckboxBind;
function bindField<T, K extends keyof T, V extends string>(
source: Signal<T[]>,
item: () => T,
key: K,
options: { as: "radio"; value: V } & Common<T>,
): RadioBind<V>;

Where Common<T> is { keyBy?: (item: T) => unknown }. The exported public type alias is BindFieldOptions<T> — import it from @whisq/core when you need to type an options object explicitly.

ParamTypeDescription
sourceSignal<T[]>The array signal that holds the items
item() => TAccessor for the current item — typically the keyed-each() callback’s first arg
keyK extends keyof TThe field on T to bind
options.as"number" | "checkbox" | "radio" (optional)Kind of input — same discriminator as bind()
options.valueV (radio only, required)The per-radio value, same as bind()
options.keyBy(item: T) => unknown (optional)Identifies which item to rewrite. Default: t => t.id
options.strictboolean (optional, since alpha.8)Pin no-match write behaviour. true = throw WhisqKeyByError in both envs; false = warn-and-discard in both envs. Default: true in dev (process.env.NODE_ENV !== "production"), false in production.

A plain props object matching the input kind — identical to bind()’s return shapes:

KindReturn typeProps
defaultTextBind{ value: () => string, oninput: (e) => void }
numberNumberBind{ value: () => string, oninput: (e) => void } (coerces)
checkboxCheckboxBind{ checked: () => boolean, onchange: (e) => void }
radioRadioBind<V>{ value: V, checked: () => boolean, onchange: (e) => void }
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().text,
),
{ key: (t) => t.id },
),
)
ul(
each(() => todos.value, (todo) =>
li(input({ ...bindField(todos, todo, "text") })),
{ key: (t) => t.id },
),
)
interface CartItem { id: string; sku: string; qty: number }
const cart = signal<CartItem[]>([/* ... */]);
each(() => cart.value, (item) =>
input({
type: "number",
min: 0,
...bindField(cart, item, "qty", { as: "number" }),
}),
{ key: (i) => i.id },
)
type Priority = "low" | "med" | "high";
interface Task { id: number; priority: Priority }
const tasks = signal<Task[]>([/* ... */]);
each(() => tasks.value, (task) =>
div(
(["low", "med", "high"] as const).map((p) =>
input({
type: "radio",
name: `priority-${task().id}`,
...bindField(tasks, task, "priority", { as: "radio", value: p }),
}),
),
),
{ key: (t) => t.id },
)

When your item identifier isn’t called id:

interface User { uuid: string; name: string }
const users = signal<User[]>([/* ... */]);
each(() => users.value, (user) =>
input({
...bindField(users, user, "name", { keyBy: (u) => u.uuid }),
}),
{ key: (u) => u.uuid },
)
  • Immutable array updates. Writes produce a new array via source.value.map(...) — downstream computed, effect, and keyed each() re-reconcile correctly. No in-place mutation, no surprises for code that depends on referential change.
  • No-match writes throw WhisqKeyByError in dev (since alpha.8), or warn-and-discard in production. See the dedicated section below.
  • Lives across component boundaries. Pass todo: () => Todo as a prop to a child component and call bindField(todos, props.todo, "done") inside — works the same as inline. The accessor stays live; the source signal is captured once and identifies items by keyBy. See /api/each/#splitting-into-a-component.
  • Type narrowing. TypeScript checks that key is a valid field of T and that the discriminator (as: "checkbox" etc.) matches the field’s expected runtime type. Misuse compiles to clear errors.
  • Runtime guards. Throws TypeError: bindField: source must be Signal if you pass an unwrapped value (e.g. bindField(todos.value, ...) instead of bindField(todos, ...)), and TypeError: bindField: item must be a function if you pass a plain object instead of the keyed-each() accessor.

Since alpha.8, a write that can’t find an item in the source array whose keyBy(...) matches the accessor’s key throws WhisqKeyByError in dev (was: console.warn + discard). The typical cause is a stale accessor (the item was removed from the source after the accessor was created) or a broken keyBy function.

// Default in vite dev: throws WhisqKeyByError on no-match writes
input({ ...bindField(todos, todo, "done", { as: "checkbox" }) });
// Opt out for a test that deliberately exercises the no-match path:
input({
...bindField(todos, todo, "done", { as: "checkbox", strict: false }),
});
strictDev (NODE_ENV !== "production")Production
(omitted, default)throws WhisqKeyByErrorwarn-and-discard
truethrows WhisqKeyByErrorthrows WhisqKeyByError
falsewarn-and-discardwarn-and-discard

WhisqKeyByError extends Error and carries:

FieldTypeDescription
sourceKeysunknown[]All keys present in the source array at write time, in source order
targetKeyunknownThe key the write was trying to match against
fieldstringThe field name being written (from bindField’s key argument)

Both the class and its WhisqKeyByErrorFields shape are exported from @whisq/core (/api/imports/). Catch it in an errorBoundary to render a friendly UI for stale-row edits, or let it bubble in tests to surface integration issues.

Writing through filtered / partitioned views

Section titled “Writing through filtered / partitioned views”

A common shape: iterate a filtered view of a list (from partition() or a computed), but let the per-row input edit the source.

Why this works: the render callback’s accessor (todo) just carries a reference to a Todo object. That same Todo also lives in the source array, so looking it up there by keyBy(todo()) (default t => t.id) matches. bindField doesn’t care which signal produced the accessor — it rewrites whichever array you hand it.

stores/todos.ts
import { signal, computed } from "@whisq/core";
import { partition } from "@whisq/core/collections";
export interface Todo { id: string; text: string; done: boolean }
export const todos = signal<Todo[]>([]);
export const filter = signal<"all" | "active" | "done">("all");
const [pending, completed] = partition(() => todos.value, (t) => !t.done);
export const filtered = computed(() => {
switch (filter.value) {
case "active": return pending.value;
case "done": return completed.value;
default: return todos.value;
}
});
components/TodoList.ts
import { component, each, ul, li, input } from "@whisq/core";
import { todos, filtered, type Todo } from "../stores/todos";
const TodoList = component(() =>
ul(
each(() => filtered.value, (todo) =>
li(
// ✅ Iterate the filtered view, but bind against the SOURCE signal.
// bindField's keyBy lookup runs against `todos`, which is where the
// item actually lives — the write lands on the real array.
input({ type: "checkbox", ...bindField(todos, todo, "done", { as: "checkbox" }) }),
() => todo.value.text,
),
{ key: (t) => t.id },
),
),
);

The same rule applies when you split the row into its own component — pass both the source signal and the accessor as props, then call bindField(props.source, props.todo, ...) inside. The source signal’s identity is captured once; the accessor stays live.

Two shapes of this mistake — one caught by the type system, one not.

Shape 1 — type-prevented. partition() and computed() both return ReadonlySignal<T[]>, and bindField’s first parameter is Signal<T[]>. Passing pending, completed, or a computed() result directly is a TypeScript error at the call site. The types catch the most common form of the mistake before runtime.

Shape 2 — type-evaded (the real footgun). A hand-rolled writable shadow signal that mirrors the filtered view typechecks but still fails:

// ❌ ANTI-PATTERN — hand-rolled writable filter view
const filteredCopy = signal<Todo[]>([]);
effect(() => {
filteredCopy.value = todos.value.filter((t) =>
filter.value === "all" ? true : filter.value === "active" ? !t.done : t.done,
);
});
each(() => filteredCopy.value, (todo) =>
input({
type: "checkbox",
// Writes update filteredCopy, not todos — the real source never sees the
// edit, and the next effect beat overwrites filteredCopy.value anyway.
// When the toggled item transitions out of the filter, its id is no
// longer in filteredCopy → WhisqKeyByError on the next write in dev.
...bindField(filteredCopy, todo, "done", { as: "checkbox" }),
}),
{ key: (t) => t.id },
);

Two failures at once: writes target the shadow array (which is about to be overwritten by the effect), and once the item stops matching the filter, its accessor can no longer find itself in filteredCopy and the write is discarded (or throws WhisqKeyByError in dev). The fix is bindField(todos, todo, ...) — nothing else changes.

ShapeHelperWhen
Single signal you ownbind(signal)One input ↔ one signal
Field inside an item inside a signal-held arraybindField(source, item, key)Per-row inputs in lists, CRUD grids, editable tables
Field inside an item where the field is a Signalbind(item().done)When you intentionally want per-item reactive isolation — toggling one checkbox re-evaluates only that item’s signal, not the array. The array signal stays still on toggle, so dependent computed/effect don’t re-fire. See todo-app example for the canonical use.

The third shape (nested-signal store + bind()) is fine and idiomatic. bindField() exists so apps that prefer plain-object item shapes don’t have to reach for the manual event-pair escape hatch.

See also: Reactive shapes cheat sheet, Forms guide → list of items, Nested Item Editing guide — which pattern to pick when (plain immutable / nested signal / extracted child), Handler collision dev warning — applies to bindField() spreads the same way as bind().

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