Arrays
Use an array node for repeatable object rows with generated child fields. The
current form value supplies the rows. The Array and ArrayItem slots supply
the add, remove, and move actions.
Use one field control when one widget edits the complete array value. For example, an async multiselect is one field, not a set of repeatable rows. See Async multiselect.
Define a repeatable row
Define the array in the schema, the initial rows, the add-action default, and the child fields:
const contactsSchema = z.object({
contacts: z
.array(
z.object({
id: z.string(),
email: z.email("Enter a valid email"),
label: z.string().optional(),
}),
)
.min(1, "Add at least one contact"),
})
const contactDefaultValues = {
contacts: [{ id: "contact-1", email: "ada@example.com", label: "Primary" }],
} satisfies FormInput<typeof contactsSchema>
const contactsDefinition = kit.defineForm(contactsSchema, {
ui: [
{
kind: "array",
path: "contacts",
label: "Contacts",
description: "Add, reorder, or remove contacts.",
itemDefault: () => ({
id: crypto.randomUUID(),
email: "",
label: undefined,
}),
children: [
{
kind: "field",
path: "email",
control: "text",
label: "Email",
required: true,
},
{
kind: "field",
path: "label",
control: "text",
label: "Label",
},
],
},
],
})These four parts have different purposes:
| Part | Purpose |
|---|---|
| Schema | Defines the array item and its validation rules. |
defaultValues | Supplies the rows that exist when the form starts. |
itemDefault | Supplies one new row for the generated add action. |
children | Defines the generated UI for one row. |
itemDefault does not add an initial row. Use defaultValues when the form
must start with one or more rows.
Child paths are relative to one item. In the example, path: "email" produces
contacts.0.email, contacts.1.email, and later paths.
See the contact array from the interactive lab
{
kind: "array",
path: "contacts",
label: "Contacts",
description:
"Add or reorder contacts. React Hook Form updates the array by index.",
itemDefault: {
email: "",
label: undefined,
},
children: [
{
kind: "field",
path: "email",
control: "text",
label: "Email",
required: true,
props: {
type: "email",
placeholder: "ada@example.com",
autoComplete: "email",
},
},
{
kind: "field",
path: "label",
control: "text",
label: "Label",
props: {
placeholder: "primary",
},
},
],
},Choose an item default
Use an object when each new row can use the same data:
itemDefault: { name: "", sessions: [] }Use a function when each new row needs a unique value:
itemDefault: () => ({
id: crypto.randomUUID(),
name: "",
sessions: [],
})The generated add action calls the function for each insertion. It then deeply
clones the result while preserving browser values such as File. Nested
objects in two generated rows do not share references.
A manual React Hook Form operation does not use itemDefault. Supply the new
item when you call append() or insert() from useFieldArray.
Define nested arrays
Put an array node inside another array node. Keep each child path relative to its immediate item:
const conferenceSchema = z.object({
speakers: z.array(
z.object({
name: z.string(),
sessions: z.array(z.object({ title: z.string() })),
}),
),
})
const conferenceDefinition = kit.defineForm(conferenceSchema, {
ui: [
{
kind: "array",
path: "speakers",
label: "Speakers",
itemDefault: { name: "", sessions: [] },
children: [
{ kind: "field", path: "name", control: "text", label: "Name" },
{
kind: "array",
path: "sessions",
label: "Sessions",
itemDefault: { title: "" },
children: [
{
kind: "field",
path: "title",
control: "text",
label: "Title",
},
],
},
],
},
],
})The same definition produces these paths at runtime:
| Definition path | Example runtime path |
|---|---|
speakers | speakers |
sessions | speakers.2.sessions |
title | speakers.2.sessions.0.title |
Generated paths use RHF dot notation for array indexes. FieldPath,
ArrayFieldPath, and PathValue use the same syntax. Generated arrays contain
object rows; use one custom control for a primitive array value.
Validate arrays and rows
Keep array length, item, and cross-row rules in the Standard Schema. This Zod example reports a duplicate email on the specific row that must change:
const uniqueContactsSchema = z
.object({
contacts: z
.array(z.object({ email: z.email("Enter a valid email") }))
.min(1, "Add at least one contact"),
})
.superRefine(({ contacts }, context) => {
const seen = new Set<string>()
for (const [index, contact] of contacts.entries()) {
const email = contact.email.toLowerCase()
if (seen.has(email)) {
context.addIssue({
code: "custom",
message: "Use a unique email",
path: ["contacts", index, "email"],
})
}
seen.add(email)
}
})"Form, Please" converts numeric issue paths to dot notation. The duplicate
issue above resolves to a path such as contacts.2.email.
After an invalid submit, the form focuses the first visible generated control with an issue. Array-level issues remain available through the array slot and the form error summary. See Validation and submission.
Understand the generated actions
The default array slots provide these actions:
- Add a row at the end.
- Move a row up or down.
- Remove a row.
The generated add action uses itemDefault. Move and remove operations update
React Hook Form state by index. All three actions enter configured value
middleware, which can cancel the action or add dependent value patches. The
source operation still uses native useFieldArray methods so row IDs survive.
Middleware cannot change the source array length or order beyond the proposed
generated action. It must not change another generated array's structure.
Dependent changes join the same final React render, but raw RHF subscriptions
can observe the native array operation before those changes. Use each array's
generated action for a managed structural change because setValues cannot
synchronize private row IDs.
Read Value middleware for transaction sources, cancellation, dependent patches, raw RHF observations, and structural limits.
When an array is disabled or read-only, the kit also disables its add, move, and remove actions. Child fields inherit that state. A hidden array keeps its value and does not render. See Conditional fields.
Add custom array operations
Use RHF useFieldArray({ control: form.api.control, name }) for
application-owned operations. This example adds a toolbar after the generated
fields:
export function ContactsForm() {
const form = kit.useForm(contactsDefinition, {
defaultValues: contactDefaultValues,
})
const { fields, insert, move, replace } = useFieldArray({
control: form.api.control,
name: "contacts",
})
return (
<kit.Form form={form}>
<kit.Fields />
<fieldset>
<legend>Contact actions</legend>
<button type="button" onClick={() => insert(0, createContact())}>
Add primary contact
</button>
<button
disabled={fields.length < 2}
type="button"
onClick={() => move(fields.length - 1, 0)}
>
Move last contact first
</button>
<button
disabled={fields.length === 0}
type="button"
onClick={() => replace([])}
>
Remove all contacts
</button>
</fieldset>
<kit.Submit>Save contacts</kit.Submit>
</kit.Form>
)
}Use these methods to add or update rows:
| Method | Result |
|---|---|
append(item) | Adds an item at the end. |
insert(index, item) | Adds an item at an index. |
update(index, item) | Replaces one item. |
Use these methods to reorder or remove rows:
| Method | Result |
|---|---|
move(from, to) | Moves one item to another index. |
swap(a, b) | Exchanges two items. |
remove(index) | Removes one item. |
replace([]) | Removes all items. |
These methods update the same React Hook Form state as the generated actions,
but direct RHF operations bypass Form, Please middleware. They do not replace
the generated array markup. Customize the Array and ArrayItem slots when you
must replace that markup or its buttons. See Controls and
slots.
Preserve row identity
RHF supplies a stable field.id for each rendered row key. A move or removal
still changes indexed public paths. For example, contacts.2 can become
contacts.1.
Put a durable ID in each item when identity must survive reordering. Generate
the ID in an itemDefault function. Do not store identity-sensitive external
state under a generated row path.
Check common mistakes
- Use
defaultValues, notitemDefault, to supply initial rows. - Use paths relative to the current array item in
children. - Use an
itemDefaultfunction when a new item needs a unique ID. - Use an array node for repeatable rows, not for one multiselect value.