Dynamic Arrays
Every FieldApi
exposes array mutation helpers, called with a path whose value is an array, that
update the immutable value and re-key the field registry, so field identity
survives a reorder:
form.pushItem("items", newItem);
form.insertItem("items", 2, newItem);
form.swapItems("items", 0, 2);
form.moveItem("items", 0, 3);
form.removeItem("items", 1);
form.replaceItem("items", 1, updatedItem);swapItems exchanges only the two given indices; everything between stays
untouched. moveItem shifts every item strictly between fromIndex and
toIndex one slot over, the same result as splicing the item out and
re-inserting it elsewhere. removeItem unregisters the removed item's field(s)
first, so they don't leak.
Addressing the node's own value
Every array method also accepts "" for name, addressing the node's own
value. A component that receives a FieldApi<Item[], TParentValue> calls
api.pushItem("", item) without needing the dotted path to it, which is what
makes the reusable components below possible.
Why re-keying matters
Without re-keying, a field bound to array index 2 would silently read/write
whatever value now lives at index 2 after a reorder, not the item the user was
actually editing. id (see Concepts) stays
stable across a reorder even as name (the index-based path) changes: it's the
right list key (key={field.id} in React, or lit-html's
repeat directive
keyed on field.id in Lit) instead of the index.
For an object item, key by one of the item's fields you already render (e.g.
field(`${i}.name`).id), not by field(`${i}`). A parent can't register
both an item ("0") and a dotted path under it ("0.name"): whichever comes
second throws. To key by the item itself, resolve it first and nest instead
(field(`${i}`).field("name")).
Rendering an array editor
There are three ways to render an array, from least to most reuse. All three edit the same value:
type Member = {
name: string;
email: string;
};
type Team = {
members: Member[];
reviewers: Member[];
skills: string[];
};One-off: Watch
For an array edited in one place, Watch with select returning the array
re-renders on every add, move, or remove. No component needed:
<Watch api={form.field("members")} select={(f) => f.value}>
{(members, value) => (
<div>
{value.map((_, i) => (
<div key={members.field(`${i}.name`).id}>
<TextField api={members.field(`${i}.name`)} label="Name" />
<TextField api={members.field(`${i}.email`)} label="Email" />
<button type="button" onClick={() => members.removeItem("", i)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => members.pushItem("", { name: "", email: "" })}
>
Add member
</button>
</div>
)}
</Watch>;Generic: ArrayField<TItem, TParentValue>
A generic component owns the subscription and the keys, and leaves everything else (layout, add/move/remove buttons) to render props. The same component then edits any item type at any path.
It can't know which fields an item has, so the caller picks the key, in
whichever way is idiomatic to the framework. In React, renderItem returns a
keyed element, just like a .map callback. In Lit, repeat takes a separate
key function, so the element takes an itemKey, defaulting to the item field's
id (right for a primitive array):
type ArrayFieldProps<TItem, TParentValue> = {
api: FieldApi<TItem[], TParentValue>;
before?: (api: FieldApi<TItem[], TParentValue>) => ReactNode;
// A single element with a `key`, like a `.map` callback's.
renderItem: (
api: FieldApi<TItem[], TParentValue>,
value: TItem,
index: number,
) => ReactElement;
after?: (api: FieldApi<TItem[], TParentValue>) => ReactNode;
};
function ArrayField<TItem, TParentValue>(
{ api, before, renderItem, after }: ArrayFieldProps<TItem, TParentValue>,
) {
const value = useWatch(api, (f) => f.value);
return (
<>
{before?.(api)}
{value.map((item, i) => renderItem(api, item, i))}
{after?.(api)}
</>
);
}
// Usage.
<ArrayField
api={form.field("members")}
renderItem={(members, _, i) => (
<div key={members.field(`${i}.name`).id}>
<TextField api={members.field(`${i}.name`)} label="Name" />
<TextField api={members.field(`${i}.email`)} label="Email" />
<button type="button" onClick={() => members.removeItem("", i)}>
Remove
</button>
</div>
)}
after={(members) => (
<button
type="button"
onClick={() => members.pushItem("", { name: "", email: "" })}
>
Add member
</button>
)}
/>
<ArrayField
api={form.field("skills")}
renderItem={(skills, _, i) => (
<TextField key={skills.field(`${i}`).id} api={skills.field(`${i}`)} />
)}
after={(skills) => (
<button type="button" onClick={() => skills.pushItem("", "")}>
Add skill
</button>
)}
/>In Lit, the slots are named renderBefore/renderAfter, since before and
after would shadow the DOM's own Element.before()/Element.after(). The
reusable array example
builds on this with move up/down buttons and an empty state.
Type-specific: MembersField<TParentValue>
When the same kind of array (with the same fields, validators, and buttons)
appears in several places, give it its own component, the same way you'd write
an AddressField.
Callers then pass only the api:
function MembersField<TParentValue>(
{ api }: { api: FieldApi<Member[], TParentValue> },
) {
const value = useWatch(api, (f) => f.value);
return (
<fieldset>
{value.map((_, i) => (
<div key={api.field(`${i}.name`).id}>
<TextField
api={api.field(`${i}.name`, { validators: required() })}
label="Name"
/>
<TextField
api={api.field(`${i}.email`, { validators: email() })}
label="Email"
/>
<button type="button" onClick={() => api.removeItem("", i)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => api.pushItem("", { name: "", email: "" })}
>
Add member
</button>
</fieldset>
);
}
// Usage.
<MembersField api={form.field("members")} />
<MembersField api={form.field("reviewers")} />A type-specific component can also be built on top of a generic ArrayField,
passing it fixed render props.
Which to use
Watch: the array appears once. Least code, nothing to name.ArrayField: many arrays share a layout or behavior, but their items differ.MembersField: the same array, items included, appears in several places, or the call site should stay one line.
What's next
- Form Composition: building reusable field
components around
useWatch/WatchController. FieldApi: full reference on JSR, including the field registry.