Nested objects and rows
An object inside the schema is reached by a dotted path, and an array of objects as a set of rows. Either way, the path is the name the browser submits.
On this page
Reach a field inside a nested object
Pass field() a dotted path. The submitted name and the key in state.errors are the same path.
schema.tsz.object({
user: z.object({
email: z.email(),
}),
});profile-form.tsxconst email = form.field('user.email');
<input {...email.input} />An object behind .optional() or .default() keeps its fields as they are.
Pitfall
Render its controls even when the object may be left out. A name missing from the FormData makes parseForm throw, since it reads as a forgotten spread.
Add and remove rows
Reach an array of objects with array(). Get each row’s fields with row.field(), and pass row.key to key.
schema.tsz.object({
items: z
.array(
z.object({
name: z.string().min(1),
quantity: z.coerce.number().int().min(1),
}),
)
.min(1)
.max(3),
});order-form.tsxconst items = form.array('items');
{items.rows.map((row) => (
<fieldset key={row.key}>
<input {...row.field('name').input} />
<input {...row.field('quantity').input} />
{items.canRemove && (
<button onClick={row.remove} type="button">
Remove
</button>
)}
</fieldset>
))}
{items.canAdd && (
<button onClick={items.add} type="button">
Add a row
</button>
)}canAdd and canRemove follow the schema’s .max() and .min(), so the buttons disappear right where the server would refuse.
React state holds only each row’s key. The values stay in the DOM, so adding or removing a row never copies them into React.
Add rows to an order
The schema allows one to three rows. Submitting shows the names and values that would be sent.
Try it
- Press “Add a row” twice. The button disappears at the third row: that is the schema’s
.max(3). - Leave the second row empty and press “Submit”. The submission stops and focus moves to the second row.
- Remove the first row, fill the rest, and submit. The indexes close up, starting again at
items[0].name.
Show an error about the number of rows
Too few rows for .min(), or too many for .max(), is an error no row’s field owns. It arrives in items.error.
order-form.tsx<form {...form.props} action={formAction}>
{items.error !== undefined && (
<p {...items.errorProps}>{items.error}</p>
)}
{items.rows.map((row) => (
<fieldset key={row.key}>{/* fields */}</fieldset>
))}
</form>Spread items.errorProps onto the element that shows it. Without them, a submission that failed only on the array moves focus nowhere, and a screen reader says nothing.
Put it above the rows: it takes focus first even when a row failed too, and Tab moves on into the rows.
The names that are submitted
A field inside a row is named with its index, as in items[0].name, and its error key is the same.
row.field('name').input.name;
// 'items[0].name'
state.errors;
// { 'items[1].quantity': 'Order at least one' }Removing a row moves the later rows up one index, and the errors the browser raised move with them.
Pitfall
Errors the server returned are not renumbered. Remove a row above one, and an error not yet fixed shows on whichever row now has that index.
How many rows render first
After a submission, the count in state.rows; otherwise the schema’s .min(); otherwise none.
parseForm reports how many rows arrived in state.rows, so a retry without JavaScript renders the same number of rows.
The count is read from the submitted indexes but never trusted beyond .max(), so a forged index cannot make the server allocate.
An array of strings
In an array of plain values such as z.array(z.string()), a row’s field has no key. Call row.field() with no argument, and its name is tags[0].
tags-form.tsxconst tags = form.array('tags');
{tags.rows.map((row) => (
<input key={row.key} {...row.field().input} />
))}Note
An array of enums (z.array(z.enum([…]))) is not rows but one field, a checkbox group. See “Field types”.
Shapes it cannot take
- A repeat inside a repeat has no single name to submit under, so
formFieldsandparseFormthrow on it. The types let it through. - Rules such as
sameAscannot name a field inside a row. Write a row field’s checks in the schema.