Skip to content
Form, Please

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:

PartPurpose
SchemaDefines the array item and its validation rules.
defaultValuesSupplies the rows that exist when the form starts.
itemDefaultSupplies one new row for the generated add action.
childrenDefines 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 pathExample runtime path
speakersspeakers
sessionsspeakers.2.sessions
titlespeakers.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:

MethodResult
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:

MethodResult
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, not itemDefault, to supply initial rows.
  • Use paths relative to the current array item in children.
  • Use an itemDefault function when a new item needs a unique ID.
  • Use an array node for repeatable rows, not for one multiselect value.