@k8ordo/form

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.ts
z.object({
  user: z.object({
    email: z.email(),
  }),
});
profile-form.tsx
const email = form.field('user.email');

<input {...email.input} />

An object behind .optional() or .default() keeps its fields as they are.

Warning

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.ts
z.object({
  items: z
    .array(
      z.object({
        name: z.string().min(1),
        quantity: z.coerce.number().int().min(1),
      }),
    )
    .min(1)
    .max(3),
});
order-form.tsx
const 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.

Playground

Add rows to an order

The schema allows one to three rows. Submitting shows the names and values that would be sent.

Row 1

Try it

  1. Press “Add a row” twice. The button disappears at the third row: that is the schema’s .max(3).
  2. Leave the second row empty and press “Submit”. The submission stops and focus moves to the second row.
  3. 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.

ts
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.

Warning

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.tsx
const tags = form.array('tags');

{tags.rows.map((row) => (
  <input key={row.key} {...row.field().input} />
))}
Information

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 formFields and parseForm throw on it. The types let it through.
  • Rules such as sameAs cannot name a field inside a row. Write a row field’s checks in the schema.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2