bindField()
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 },)Signature
Section titled “Signature”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.
Parameters
Section titled “Parameters”| Param | Type | Description |
|---|---|---|
source | Signal<T[]> | The array signal that holds the items |
item | () => T | Accessor for the current item — typically the keyed-each() callback’s first arg |
key | K extends keyof T | The field on T to bind |
options.as | "number" | "checkbox" | "radio" (optional) | Kind of input — same discriminator as bind() |
options.value | V (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.strict | boolean (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. |
Returns
Section titled “Returns”A plain props object matching the input kind — identical to bind()’s return shapes:
| Kind | Return type | Props |
|---|---|---|
| default | TextBind | { value: () => string, oninput: (e) => void } |
number | NumberBind | { value: () => string, oninput: (e) => void } (coerces) |
checkbox | CheckboxBind | { checked: () => boolean, onchange: (e) => void } |
radio | RadioBind<V> | { value: V, checked: () => boolean, onchange: (e) => void } |
Examples
Section titled “Examples”Checkbox per item
Section titled “Checkbox per item”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 }, ),)Editable text field per item
Section titled “Editable text field per item”ul( each(() => todos.value, (todo) => li(input({ ...bindField(todos, todo, "text") })), { key: (t) => t.id }, ),)Number input per item
Section titled “Number input per item”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 },)Radio per item (e.g. priority picker)
Section titled “Radio per item (e.g. priority picker)”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 },)Custom keyBy
Section titled “Custom keyBy”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 },)Semantics
Section titled “Semantics”- Immutable array updates. Writes produce a new array via
source.value.map(...)— downstreamcomputed,effect, and keyedeach()re-reconcile correctly. No in-place mutation, no surprises for code that depends on referential change. - No-match writes throw
WhisqKeyByErrorin dev (since alpha.8), or warn-and-discard in production. See the dedicated section below. - Lives across component boundaries. Pass
todo: () => Todoas a prop to a child component and callbindField(todos, props.todo, "done")inside — works the same as inline. The accessor stays live; the source signal is captured once and identifies items bykeyBy. See/api/each/#splitting-into-a-component. - Type narrowing. TypeScript checks that
keyis a valid field ofTand 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 Signalif you pass an unwrapped value (e.g.bindField(todos.value, ...)instead ofbindField(todos, ...)), andTypeError: bindField: item must be a functionif you pass a plain object instead of the keyed-each()accessor.
Dev-mode behaviour and the strict option
Section titled “Dev-mode behaviour and the strict option”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 writesinput({ ...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 }),});strict | Dev (NODE_ENV !== "production") | Production |
|---|---|---|
| (omitted, default) | throws WhisqKeyByError | warn-and-discard |
true | throws WhisqKeyByError | throws WhisqKeyByError |
false | warn-and-discard | warn-and-discard |
WhisqKeyByError extends Error and carries:
| Field | Type | Description |
|---|---|---|
sourceKeys | unknown[] | All keys present in the source array at write time, in source order |
targetKey | unknown | The key the write was trying to match against |
field | string | The 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.
Worked example
Section titled “Worked example”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; }});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.
Why not just pass filtered to bindField?
Section titled “Why not just pass filtered to bindField?”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 viewconst 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.
When to use which
Section titled “When to use which”| Shape | Helper | When |
|---|---|---|
| Single signal you own | bind(signal) | One input ↔ one signal |
| Field inside an item inside a signal-held array | bindField(source, item, key) | Per-row inputs in lists, CRUD grids, editable tables |
Field inside an item where the field is a Signal | bind(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 →