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.