OfficeGest
Form Builder

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:

PropTypeNotes
type"array"The discriminator. Required.
labelstringi18n key for the array's collective label, shown as the fieldset legend.
itemStaticFormFields<...>Field config for ONE element of the array. See below.
addLabelstringi18n key for the "add row" button. Defaults to forms.array.add.
removeLabelstringi18n key for each row's remove button. Defaults to forms.array.remove.
colSpanColSpanColumn span of the whole array block inside its parent grid.
minItemsnumberUI hint only — hard validation stays in Zod (z.array(...).min(N)).
maxItemsnumberOnce 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:

OrderKey tried
1customer.errors.contacts.0.value.X
2customer.errors.contacts.0.value
3customer.errors.contacts.value.X (index stripped)
4customer.errors.contacts.value (index stripped)
5forms.errors.X
6Zod'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.