Array Fields
Repeatable groups of fields with row-level validation and add/remove controls.
The array field type renders a repeatable fieldset — a list of rows, each row being a small group of fields bound to one element of a z.array(z.object(...)) schema. It comes with native add/remove controls, per-row validation that surfaces inline at the failing row, and an optional maxItems cap.
Use it for open-ended collections (contacts, line items, attachments) where the user can grow or shrink the list at runtime.
const schema = z.object({
contacts: z
.array(
z.object({
kind: z.enum(["phone", "email", "address"]),
value: z.string().min(1, "Value is required"),
}),
)
.default([]),
});Always give the array branch a .default([]). The form starts the field as an empty list unless you seed defaultValues, and the schema needs a valid empty state for the initial parse.
Config
The array entry in the field-type registry has the following shape:
| Prop | Type | Notes |
|---|---|---|
type | "array" | The discriminator. Required. |
label | string | i18n key for the array's collective label, shown as the fieldset legend. |
item | StaticFormFields<...> | Field config for ONE element of the array. See below. |
addLabel | string | i18n key for the "add row" button. Defaults to forms.array.add. |
removeLabel | string | i18n key for each row's remove button. Defaults to forms.array.remove. |
colSpan | ColSpan | Column span of the whole array block inside its parent grid. |
minItems | number | UI hint only — hard validation stays in Zod (z.array(...).min(N)). |
maxItems | number | Once this many rows exist, the Add button is removed. See maxItems. |
The item map
item is a map keyed by the element-schema's field names, where each value is a field config — exactly like a top-level fields map, but one level down. Each item field gets its own colSpan, so a row composes as a mini-grid.
const fields: FormFields<typeof schema> = {
contacts: {
type: "array",
label: "Contacts",
addLabel: "Add contact",
removeLabel: "Remove",
item: {
kind: {
type: "select",
label: "Kind",
options: CONTACT_KINDS,
colSpan: {base: 12, md: 4},
},
value: {
type: "text",
label: "Value",
placeholder: "+351 123 456 789",
colSpan: {base: 12, md: 8},
},
},
},
};Item configs are static. The item map is typed as StaticFormFields, which strips the function-form variant from disabled, required, visible, and options. Writing a predicate function inside an item config — e.g. disabled: (values) => ... — is a compile error, not a silent no-op. The array primitive does not subscribe to form values per row, so a runtime predicate there would never fire. Use static booleans and static option arrays only.
Defaults on add
Clicking the Add button pushes a new row whose every item-config key is set to undefined. The individual field renderers coerce undefined to their own empty representation at their boundary (undefined → "" for text, false for booleans, [] for multi-selects), so no field-type-specific defaulting happens in the array primitive.
The undefined choice is deliberate: it keeps a freshly-added row distinguishable from one the user has actually filled in, so submit-time validation can flag it rather than letting an empty string slip through.
To seed pre-existing rows, pass them through defaultValues:
const form = useFormBuilder({
formId: "playground-contacts",
schema,
fields,
defaultValues: {
contacts: [{kind: "phone", value: "+351 000 000 000"}],
},
onSubmit: (values) => setLastSubmit(values),
});maxItems
maxItems caps how many rows the user can add. The behaviour is not a disabled Add button — once the cap is reached the Add button is removed entirely and replaced by a muted status hint:
items: {
type: "array",
label: "Line items",
addLabel: "Add line",
removeLabel: "Remove",
maxItems: 3,
item: {
sku: {type: "text", label: "SKU", colSpan: {base: 12, md: 4}},
quantity: {type: "number", label: "Qty", min: 1, colSpan: {base: 6, md: 2}},
description: {type: "text", label: "Description", colSpan: {base: 12, md: 6}},
},
},When the list has fewer than maxItems rows, the Add button shows. At the cap, a role="status" hint ("Maximum N items reached.") takes its place. Removing a row brings the Add button back. minItems is purely informational at the UI layer — enforce a lower bound with z.array(...).min(N) in the schema.
The cap is a UI affordance only. To hard-enforce bounds on submit, add .min() / .max() to the array in your Zod schema — maxItems alone does not block a programmatically-set over-cap value from validating.
Row-level validation
Per-row validation comes straight from the element schema. A z.object field constraint (value: z.string().min(1)) applies to every row independently, and the error surfaces inline at the specific failing row — valid rows are left untouched.
Submitting a form with one empty row marks only that row's failing field with an inline error; the other rows pass through cleanly.
Array field and validator paths use bracket notation for the row index: contacts[0].value, not contacts.0.value. This is TanStack Form's canonical accessor for nested arrays — registering with dot notation would register the field but fail to match validator-produced error paths, so row-level errors would never reach the right field.
You do not need per-row i18n keys. The error-map collapses numeric path segments when resolving error strings: a row-level error at contacts.0.value falls back to the wildcard key contacts.value. So one key covers every row of the array. (This collapse only kicks in for paths that actually contain a numeric segment — non-array paths are unaffected.)
The error-map resolution chain for an array path, on a form with formId = customer and an error code X at contacts.0.value:
| Order | Key tried |
|---|---|
| 1 | customer.errors.contacts.0.value.X |
| 2 | customer.errors.contacts.0.value |
| 3 | customer.errors.contacts.value.X (index stripped) |
| 4 | customer.errors.contacts.value (index stripped) |
| 5 | forms.errors.X |
| 6 | Zod's localized default |
Steps 3 and 4 — the wildcard tries — exist precisely so array forms can key error strings once instead of per-row.
Complete example
A self-contained open-ended list, seeded with one row:
const CONTACT_KINDS: SelectOption[] = [
{label: "Phone", value: "phone"},
{label: "Email", value: "email"},
{label: "Address", value: "address"},
];
const contactsSchema = z.object({
contacts: z
.array(
z.object({
kind: z.enum(["phone", "email", "address"]),
value: z.string().min(1, "Value is required"),
}),
)
.default([]),
});
const contactsFields: FormFields<typeof contactsSchema> = {
contacts: {
type: "array",
label: "Contacts",
addLabel: "Add contact",
removeLabel: "Remove",
item: {
kind: {
type: "select",
label: "Kind",
options: CONTACT_KINDS,
colSpan: {base: 12, md: 4},
},
value: {
type: "text",
label: "Value",
placeholder: "+351 123 456 789",
colSpan: {base: 12, md: 8},
},
},
},
};
function ContactsCard(): ReactElement {
const form = useFormBuilder({
formId: "playground-contacts",
schema: contactsSchema,
fields: contactsFields,
defaultValues: {
contacts: [{kind: "phone", value: "+351 000 000 000"}],
},
onSubmit: (values) => console.log(values),
});
return (
<form.Provider>
<form.Fields names={["contacts"]} />
<form.SubmitButton>Save</form.SubmitButton>
</form.Provider>
);
}Define a Zod schema where the field is z.array(z.object(...)).default([]). The element object's fields become the array's row shape.
Add an array entry to your FormFields map. Give it a label, optional addLabel / removeLabel, and an item map mirroring the element-object keys — each item field with its own colSpan.
Optionally seed initial rows through defaultValues, and cap growth with maxItems.
Render it like any other field: form.Fields names={["contacts"]} inside form.Provider. Add / Remove controls and row-level validation are wired automatically.