Field arrays
This page covers useFieldArray: rendering array fields with stable React keys, the operations it exposes, how id reconciliation keeps keys glued to rows, nested arrays, array-level errors, and how per-row metadata (errors, touched state, server verdicts) follows rows through reorders.
useFieldArray(form, path)
import { useFieldArray, useForm } from "formstand";
import { z } from "zod";
const schema = z.object({
users: z.array(z.object({ email: z.string(), name: z.string() })).min(1, "add at least one user"),
});
const UsersEditor = ({ form }: { form: Form<typeof schema> }) => {
// The item type is inferred from the schema through the path, so push()
// below knows a user is { email: string; name: string }.
const users = useFieldArray(form, "users");
return (
<>
{users.fields.map((field, i) => (
<UserRow key={field.id} form={form} index={i} onRemove={() => users.remove(i)} />
))}
{users.firstError ? <p role="alert">{users.firstError}</p> : null}
<button type="button" onClick={() => users.push({ email: "", name: "" })}>
Add user
</button>
</>
);
};The hook returns:
fields:readonly { id: string; value: TItem }[]; usefield.idas the Reactkey.items: the raw array values (readonly TItem[]).length: the current length.error: the array-level error, for example fromz.array().min(1), keyed at the array's own path — plusfirstError, the first message orundefined, same shorthand asuseField.path: the resolved array path, andsetError(errors)/clearError(): the array-level server channel, so a "too many rows" verdict from your API is set and cleared from the hook that owns the array (same semantics asfield.setError).push(item),remove(index),insert(index, item),move(from, to),swap(a, b): wrappers over the form'sarrayPush,arrayRemove,arrayInsert,arrayMove, andarraySwapthat also revalidate the array path when the form's validation mode calls for it, under the same gate as a field edit (mode,reValidateMode, submit state). That is why an array-level error likemin(1)clears the moment a row is added rather than on the next submit. The imperativeform.arrayPush(...)family stays validation-silent, exactly likeform.setValue, because event-driven validation belongs to the hooks.
With a Form<TSchema> and a typed path, including template paths like `albums.${index}.tracks`, TItem is inferred from the schema and needs no type argument. The explicit useFieldArray<TItem>(form, path) form is for schema-less FieldFormApi forms, where there is nothing to infer from. Passing it alongside a typed form is a compile error that tells you the fix: the path argument is blamed with "Remove the explicit type argument: a schema-typed form infers the item type from the path" (see Typed paths). Dynamic paths via a selector function return UseFieldArrayReturn<unknown>, like useField.
The path can also be a selector function, like useField's. See Typed paths.
Stable ids
React keys must follow items, not indices, or removing row 0 makes every row re-mount with its neighbor's state. useFieldArray derives a stable id per item, and the id state is shared per (form, path), so every hook instance on the same array sees the same ids:
- Array ops replay exactly.
push,remove,insert,move, andswap, whether called on the hook or imperatively asform.arrayMove, record their precise index mapping in the form, and id derivation replays it. Even rows with equal values keep the right ids through ops. - Everything else reconciles by identity/value. Whole-array writes (
setValue,restore, resets) carry no mapping, so ids follow item identity, with a positional fallback so an in-place edit (a fresh item object at the same position) updates its row instead of remounting it. - Genuinely new items get fresh ids. Ids never repeat for a given form and path.
- A hook whose dynamic
pathchanges reads the target path's shared id state, so two hooks pointed at the same array always agree.
Duplicate values in whole-array writes are best-effort
Array ops track duplicates exactly (see above). A whole-array write is the remaining ambiguity: after setValue("tags", reordered) the intent for Object.is-equal rows is unknowable, since ["a", "a"] reordered is identical to itself, so duplicates match in order. If rows carry focus or animation state and you rewrite whole arrays, prefer objects ({ id, label }) over bare primitives. Exact op tracking is a createForm and useForm feature; a hand-rolled FieldArrayFormApi implementation reconciles by value only.
Nested arrays
Field arrays nest without ceremony. Each level gets its own hook, and paths compose with template literals (adapted from the repo's NestedArraysForm example):
const schema = z.object({
albums: z.array(
z.object({
title: z.string().min(1, "title required"),
tracks: z.array(
z.object({ title: z.string().min(1), durationMin: z.number().positive() }),
).min(1, "at least one track"),
}),
).min(1, "at least one album"),
});
const AlbumRow = ({ form, index }: { form: Form<typeof schema>; index: number }) => {
const title = useField(form, `albums.${index}.title`);
const tracks = useFieldArray(form, `albums.${index}.tracks`);
return (
<fieldset>
<input {...textInputProps(title)} placeholder="album title" />
{tracks.fields.map((field, trackIndex) => (
<TrackRow key={field.id} form={form} albumIndex={index} trackIndex={trackIndex} />
))}
{tracks.firstError ? <p role="alert">{tracks.firstError}</p> : null}
<button type="button" onClick={() => tracks.push({ title: "", durationMin: 1 })}>
+ add track
</button>
</fieldset>
);
};Both directions work: form.arrayPush("albums.0.tracks", track) mutates the inner array, and reordering the outer albums array correctly re-keys metadata for all nested paths. Note that inner ids belong to the path (albums.0.tracks), not to a particular album, so after an outer reorder the path hosts a different album's tracks and ids reconcile by value against them. Pass a key from the outer fields so each album's whole row subtree, inner hook included, moves with its album instead of being re-derived at a new index.
Array-level errors
Constraints on the array itself (z.array(...).min(1), .max(n), a .refine on the array) produce errors keyed at the array's path, exposed as useFieldArray(...).error and distinct from per-row errors like albums.0.tracks.1.title:
{tracks.firstError ? <p role="alert">{tracks.firstError}</p> : null}The hook's ops keep this error live: once the validation gate is open (after the first blur in the default onBlur mode, after a failed submit in onSubmit mode, or immediately in onChange mode), push past a max(n) raises the error and push-ing the missing row under a min(1) clears it, with no second submit needed. Custom FieldArrayFormApi implementations opt in by providing the optional validateField(path) member; without it, ops simply skip revalidation (API notes).
Metadata follows rows
Array ops don't just move values. errors, touched, and server verdicts are re-keyed through the same index mapping, so they stay attached to their rows:
- After
remove(0), an error onitems.1.namebecomes an error onitems.0.name: same row, new index. - A server error on a row survives a reorder, since that row's value didn't change. A server verdict on the array itself or an ancestor is released, because the op changed that value.
- Dirtiness is derived, not stored, so
pushfollowed byremovereads clean again. - In-flight
isValidatingflags under the array are dropped, not re-keyed. The async pass that set one no longer matches the reshaped rows and its result will be discarded as stale, so re-keying the flag would show a spinner that no pass is going to clear.
Out-of-range or non-integer indices are refused with a console warning rather than corrupting the re-keyed maps, and an op on a path whose value isn't an array is skipped with a warning.
Next
- Errors: schema & server: the release contract server errors follow through array ops.
- Form state & lifecycle: derived dirtiness and
diff()over arrays.