Skip to content
Chapter 45Lesson 4

useFieldArray: dynamic lists of fields

React Hook Form's useFieldArray hook, for forms that own a variable-length list of rows the user adds, removes, and reorders.

The invoice form you built over the last two lessons has three scalar fields: a customer, an email, and a total. A real invoice also has a list of line items, and the user decides how many: one line for a quick consultation, a dozen for an itemized month. A form that owns a variable-length list is one of the four triggers named at the start of this chapter, and useFieldArray is its tool.

By the end, that form grows an add-and-remove line-items section: its total is computed from the rows, it validates each row and the “at least one line” rule, it reorders, and it saves through the same createInvoice action, turning the edited list into the right mix of database inserts, updates, and deletes.

Four manual jobs a variable-length list forces on native forms

Section titled “Four manual jobs a variable-length list forces on native forms”

Picture line items on the native pattern from the last chapter: uncontrolled inputs identified by name, read out of a flat FormData. A FormData is flat and a list is not, so you fake the nesting with indexed name strings generated from a counter:

{rowIds.map((rowId, index) => (
<div key={rowId}>
<input name={`lineItems[${index}].description`} defaultValue="" />
<input name={`lineItems[${index}].amount`} defaultValue="" />
</div>
))}

That one map hides four bookkeeping jobs, and you own all four by hand:

  1. The indexed name strings. lineItems[0].amount, lineItems[1].amount, and so on, each spliced from the row’s position, and the server has to parse that convention back into an array.
  2. A parallel useState array of row keys. The rowIds above hold no data the user cares about; they exist only to give React a stable key and drive the add and remove buttons.
  3. Manual re-indexing when a middle row is removed. Delete row 1 of three and rows 2 and 3 must renumber, or their name strings collide and the FormData you read on submit is wrong.
  4. Per-row error rendering. The schema validates lineItems as an array, so a bad amount on row 2 lives at lineItems[2].amount, and matching each path back to the right input is its own pile of work.

None of these jobs is about the invoice; all of them force a flat, string-keyed form to hold a nested, growable structure. Once a form owns a set of repeated rows whose count the user controls, useFieldArray is the standard tool. It owns all four jobs, the array structure, the identity keys, the re-indexing, and the per-row error wiring, behind one hook call. It does not change the trust boundary: the action still runs InvoiceSchema.safeParse(input) on entry, array and all.

What useFieldArray returns: fields and operations

Section titled “What useFieldArray returns: fields and operations”

useFieldArray plugs into the form you already have. You hand it the control from the form the page created in the primitives lesson, name the array field it should manage, and it returns an array to render plus operations to mutate that array:

const { fields, append, remove, move, replace } = useFieldArray({
control: form.control,
name: 'lineItems',
});
const addLine = () => append({ description: '', amount: 0 });

The hook returns the array RHF tracks (fields) plus a set of operations. This lesson reaches for append, remove, move, and replace; a few more (prepend, insert, swap, update) are summarized below.

const { fields, append, remove, move, replace } = useFieldArray({
control: form.control,
name: 'lineItems',
});
const addLine = () => append({ description: '', amount: 0 });

control is the same one from the form you already created; it lets the hook read and write this form rather than a new one. name is the array field’s path in the schema; it must match the lineItems key exactly.

const { fields, append, remove, move, replace } = useFieldArray({
control: form.control,
name: 'lineItems',
});
const addLine = () => append({ description: '', amount: 0 });

fields is a render-time snapshot of the array, each entry carrying RHF’s own id plus the row’s values. Map over it to render rows. It is not the live value: to read what the user has typed right now, use useWatch, a few sections down.

const { fields, append, remove, move, replace } = useFieldArray({
control: form.control,
name: 'lineItems',
});
const addLine = () => append({ description: '', amount: 0 });

append adds a row. Pass the full default shape, every field the schema requires. This is the same defaultValues discipline from the primitives lesson, applied per row; an empty append({}) would leave the new inputs uncontrolled.

1 / 1

The operations are a small imperative API you call from event handlers, and their names say what they do:

const { append, prepend, insert, remove, swap, move, replace } = fieldArray;

These operations also absorb re-indexing. When you remove(1) from a three-row list, RHF slides the rows up, migrates each row’s validation and dirty state to its new position, and re-keys everything internally, so you never splice an array or renumber a name string. The class of bug where deleting a middle row misaligns the form’s data is gone.

Render the list by mapping over fields. Each entry is a row: its inputs go through the Field + Controller layer from earlier, followed by a “Remove” button, with one “Add line” button below the list.

app/invoices/new-invoice-form.tsx
{fields.map((field, index) => (
<FieldGroup key={field.id}>
<Controller
name={`lineItems.${index}.description`}
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Description</FieldLabel>
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
<Button type="button" variant="ghost" onClick={() => remove(index)}>
Remove
</Button>
</FieldGroup>
))}
<Button type="button" variant="outline" onClick={addLine}>
Add line
</Button>

Most of this is familiar: the Controller render prop, the field spread, fieldState driving the error row. New are the field path templated with the row index (lineItems.${index}.description) and the row keyed by field.id. Read the keys carefully, because there are two different fields. The outer one, from fields.map((field, index) => …), is the array row, and its id is the key. The inner one, from Controller’s render={({ field }) => …}, is the per-input wiring bundle you already know. Same name, different objects, and only the outer one carries id. Get this key wrong and the bug is invisible in the code and obvious the moment you click “Remove.”

Here is the trap. key={index} looks fine, rows render and add and remove work, until you remove a row that isn’t last.

{fields.map((field, index) => (
<FieldGroup key={index}>
{/* row inputs */}

Breaks on remove. The key is the row’s position, not its identity. Remove a middle row and every row below shifts up by one, so React sees the key at that position unchanged, keeps the old DOM node, and pours the next row’s data into it. Focus jumps, half-typed text lands on the wrong line, dirty flags migrate, exit animations fire on the wrong node.

You met this rule earlier as a convention: lists need a stable key tied to data identity, never the array index for reorderable lists. useFieldArray is built for the reorderable case, so the index key is wrong on every operation but “append to the end.” That is what field.id is for. Use it, and forget the array index for this map.

Scrub below to watch the bug: three rows A, B, C, with focus and half-typed text in row C, then row B removed.

Editing row C
key={index}
0
A · Description
Hosting
1
B · Description
Domain
2
C · Description
Desig
key={field.id}
0
A · Description
Hosting
1
B · Description
Domain
2
C · Description
Desig
Three rows. The user is mid-type in row C — focus ring on it, “Desig…” half-entered. Both columns look identical so far.
Removed row B
key={index}
0
A · Description
Hosting
B · Description
Domainremoved
1
C · Description
Desig
key={field.id}
0
A · Description
Hosting
B · Description
Domainremoved
1
C · Description
Desig
The user clicks Remove on row B. RHF drops B from the array; rows A and C remain, and C is now at position 1 (it used to be 2).
Same remove · opposite outcomes
key={index} — bug
0
A · Description
Hosting
B · Description
Domainremoved
1
C · Description
Desig
key={field.id} — correct
0
A · Description
Hosting
B · Description
Domainremoved
1
C · Description
Desig
key={index}: React matched nodes by position. Position 1’s node — the one that held B — is reused for C’s data, but the focus and the typed text stayed with the physical node. The ring and “Desig…” are now on the wrong row. key={field.id}: React tracked C by identity — the ring and text are still on C.
field.id moved the whole node
key={index} — bug
0
A · Description
Desig
1
C · Description
(empty)
 
 
key={field.id} — correct
0
A · Description
Hosting
1
C · Description
Desig
 
 

One character’s difference — index teleports the cursor and the half-typed text onto the wrong line; field.id moves the whole node with the row.

key={field.id}: React tracked C’s node by its identity, so it moved the whole node — value, focus, dirty state — up with the row. Nothing jumped. That is the entire difference.

The left column is the bug that ships: a user editing line 3 of an invoice clicks “remove line 1,” and their cursor and half-typed amount silently jump to another row. The right column differs by one character. That is what field.id buys you.

The snippet below is a minimal line-items list with useFieldArray: plain <input>s, no resolver, no Field layer, so the keying point stands alone. Two spots are blank, the row’s key and the argument to append.

Two blanks: the row's `key` and the argument to `append`. Pick the stable identity for the key, and the full default row for `append`. Pick the right option from each dropdown, then press Check.

export function App() {
const { control, register } = useForm({
defaultValues: { lineItems: [{ description: '', amount: 0 }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'lineItems',
});
return (
<div>
{fields.map((field, index) => (
<div key={___}>
<input {...register(`lineItems.${index}.description`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append(___)}>
Add line
</button>
</div>
);
}

The Zod array schema and its two error paths

Section titled “The Zod array schema and its two error paths”

The hook manages the array; the schema describes its shape. That shape lives in the feature’s invoice-schema.ts, imported by both the action and the form, so holding line items starts there.

Add a lineItems array to InvoiceSchema:

app/invoices/_lib/invoice-schema.ts
export const InvoiceSchema = z.object({
customer: z.string().min(1, 'Customer is required'),
email: z.email('Enter a valid email'),
total: z.coerce.number<number>().positive(),
lineItems: z
.array(
z.object({
id: z.uuid().optional(),
description: z.string().min(1, 'Describe the line'),
amount: z.coerce.number<number>().positive('Amount must be positive'),
}),
)
.min(1, 'Add at least one line'),
});

Three details carry weight. amount uses z.coerce.number<number>(), the generic-pinned coercion from the resolver lesson: it accepts the string an <input> produces and yields a number, while the pinned generic keeps the input type a clean number so defaultValues and the registered field type-check. The .min(1, 'Add at least one line') on the array forbids an empty invoice. And id: z.uuid().optional() is the one to watch.

That id is not field.id. It is the line item’s domain ID, its database primary key, if it has one. A row the user just added has none yet (undefined); a row loaded from an existing invoice carries the one the database assigned. Present means “update this existing line” when you save the array later, absent means “insert this new one.”

Now the second pitfall: where the errors land. A field array reports errors at two different paths, and people reliably look for each in the wrong place.

The first is the per-row error. A bad amount on row 2 lands at errors.lineItems[2].amount. You rarely read that raw path: each row renders through a Controller scoped to lineItems.${index}.amount, and the fieldState its render prop hands you is already that row’s error, so <FieldError errors={[fieldState.error]} /> inside the row renders it with no path arithmetic. You only need to recognize the shape when you meet it in formState.errors.

The second is the array-level error, the .min(1, 'Add at least one line') one, and it is the trap. It attaches to no row, so it never appears inside the Controllers. And it is not at errors.lineItems.message, where most people look. It lives at errors.lineItems.root.message:

{form.formState.errors.lineItems?.root != null && (
<FieldError errors={[form.formState.errors.lineItems.root]} />
)}

The root error slot holds errors belonging to the array as a whole, not to any one element. Render it once below “Add line,” so an empty list shows “Add at least one line” right where the user would go to fix it.

Both reads are the resolver’s client-side view of the same schema the action parses with: the rules and messages are written once in the schema file and read by both sides.

The form’s total was a typed field. On a real invoice the total is the sum of the line amounts, and asking the user to keep it in sync invites a wrong number. Derive it instead: make total read-only and computed from the rows, updating live as amounts change. That means reading the array’s current values, where the snapshot caveat comes due.

Recall that fields is a render-time snapshot: its amounts are frozen at the last render, useless for a running sum. To read what the user has typed right now, you have two tools, and the choice is whether you need to react to changes:

  • form.getValues('lineItems') reads the current array once and does not subscribe, so it triggers no re-render. Right for a read inside an event handler.
  • useWatch({ control: form.control, name: 'lineItems' }) returns the current array and subscribes, re-rendering the caller whenever an amount changes. Right for a live-updating total.

The total wants the subscription. But a subscription in the form root re-renders the entire form on every keystroke, so scope it: drop the useWatch into a small leaf component that renders only the total.

const InvoiceTotal = ({ control }: { control: Control<InvoiceInput> }) => {
const lineItems = useWatch({ control, name: 'lineItems' });
const total = lineItems.reduce(
(sum, line) => sum + (Number(line.amount) || 0),
0,
);
return <output>{total.toFixed(2)}</output>;
};

useWatch subscribes this leaf to the live lineItems array. Because the subscription lives here, not in the form root, only this component re-renders when an amount changes.

const InvoiceTotal = ({ control }: { control: Control<InvoiceInput> }) => {
const lineItems = useWatch({ control, name: 'lineItems' });
const total = lineItems.reduce(
(sum, line) => sum + (Number(line.amount) || 0),
0,
);
return <output>{total.toFixed(2)}</output>;
};

Sum the amounts. The watched value is the input shape, so an amount mid-edit can be '' (or NaN after Number); the || 0 keeps the running total a number while the user types. The schema’s coercion cleans it on submit.

const InvoiceTotal = ({ control }: { control: Control<InvoiceInput> }) => {
const lineItems = useWatch({ control, name: 'lineItems' });
const total = lineItems.reduce(
(sum, line) => sum + (Number(line.amount) || 0),
0,
);
return <output>{total.toFixed(2)}</output>;
};

<output> is the semantic element for a calculated result, and the total is a consequence of the lines, not a field for the user to edit.

1 / 1

Drop <InvoiceTotal control={form.control} /> in wherever the total should display, and delete the old total input. This is the z.input versus z.output split from the resolver lesson: RHF tracks a string mid-edit, the schema produces a coerced number on submit.

Row order matters when it prints on the invoice. move(from, to) slides a row to a new index and re-indexes the rest, carrying each row’s field state with it. Because the key is field.id, not the index, the survivors keep their identity and the reorder animates cleanly instead of flickering, the same key that made removal correct.

For a list a user reorders now and then, two buttons per row are enough, with no new dependency:

app/invoices/new-invoice-form.tsx
<Button
type="button"
variant="ghost"
disabled={index === 0}
onClick={() => move(index, index - 1)}
>
Move up
</Button>
<Button
type="button"
variant="ghost"
disabled={index === fields.length - 1}
onClick={() => move(index, index + 1)}
>
Move down
</Button>

The disabled guards keep the indices in bounds: no “move up” on the first row, no “move down” on the last.

For true drag-to-reorder on a long list, the integration point is the same. Pair move with a drag-and-drop library: it fires a callback on drag end with the source and target indices, and your handler is one line, move(from, to). The 2026 library is @dnd-kit, wired up as onDragEnd → move(from, to). The reference below has the full drag example.

Saving the array: the insert/update/delete diff

Section titled “Saving the array: the insert/update/delete diff”

The form now edits a list, so the last question is what the server does with it, and this is where the id? field you added to the schema earns its place. The action reconciles the submitted set against the database: the submitted list is the new source of truth, and the action makes the database match it.

The call is unchanged from the resolver lesson: onSubmit(values) hands RHF’s typed object to await createInvoice(values), which still parses first with InvoiceSchema.safeParse(input). (The chapter’s project keeps FormData by design; this RHF form is the typed-object caller.)

What’s new is the action’s body. Once it has parsed the array, it diffs the submitted rows against the database by each row’s domain id:

export async function createInvoice(input: Invoice) {
const parsed = InvoiceSchema.safeParse(input);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const submitted = parsed.data.lineItems;
const existingIds = await listLineItemIds(parsed.data.id);
const submittedIds = new Set(submitted.map((line) => line.id));
const saved = await db.transaction(async (tx) => {
const toInsert = submitted.filter((line) => line.id == null);
const toUpdate = submitted.filter((line) => line.id != null);
const toDelete = existingIds.filter((id) => !submittedIds.has(id));
// tx.insert(toInsert) · tx.update(toUpdate) · tx.delete(toDelete)
});
revalidateTag('invoices');
return ok(saved);
}

Parse first: the array crossed the wire, so it gets the same safeParse gate as every other field. Bail before touching the database, returning the flat fieldErrors the Result contract expects.

export async function createInvoice(input: Invoice) {
const parsed = InvoiceSchema.safeParse(input);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const submitted = parsed.data.lineItems;
const existingIds = await listLineItemIds(parsed.data.id);
const submittedIds = new Set(submitted.map((line) => line.id));
const saved = await db.transaction(async (tx) => {
const toInsert = submitted.filter((line) => line.id == null);
const toUpdate = submitted.filter((line) => line.id != null);
const toDelete = existingIds.filter((id) => !submittedIds.has(id));
// tx.insert(toInsert) · tx.update(toUpdate) · tx.delete(toDelete)
});
revalidateTag('invoices');
return ok(saved);
}

Load the line IDs currently in the database for this invoice, and gather the submitted IDs into a set. The diff compares current truth against desired truth.

export async function createInvoice(input: Invoice) {
const parsed = InvoiceSchema.safeParse(input);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const submitted = parsed.data.lineItems;
const existingIds = await listLineItemIds(parsed.data.id);
const submittedIds = new Set(submitted.map((line) => line.id));
const saved = await db.transaction(async (tx) => {
const toInsert = submitted.filter((line) => line.id == null);
const toUpdate = submitted.filter((line) => line.id != null);
const toDelete = existingIds.filter((id) => !submittedIds.has(id));
// tx.insert(toInsert) · tx.update(toUpdate) · tx.delete(toDelete)
});
revalidateTag('invoices');
return ok(saved);
}

The split, keyed on the domain id. No id means INSERT, a row the user appended. An id present means UPDATE, an existing row edited in place. An ID in the database but absent from the submission means DELETE, a row the user removed. Deletion is inferred from absence, never sent as an instruction, so the submitted list has to be the complete desired state.

export async function createInvoice(input: Invoice) {
const parsed = InvoiceSchema.safeParse(input);
if (!parsed.success) {
return err(
'validation',
'Check the highlighted fields.',
z.flattenError(parsed.error).fieldErrors,
);
}
const submitted = parsed.data.lineItems;
const existingIds = await listLineItemIds(parsed.data.id);
const submittedIds = new Set(submitted.map((line) => line.id));
const saved = await db.transaction(async (tx) => {
const toInsert = submitted.filter((line) => line.id == null);
const toUpdate = submitted.filter((line) => line.id != null);
const toDelete = existingIds.filter((id) => !submittedIds.has(id));
// tx.insert(toInsert) · tx.update(toUpdate) · tx.delete(toDelete)
});
revalidateTag('invoices');
return ok(saved);
}

All three writes run in one transaction: more than one row changes, so it is all-or-nothing. Revalidate after the write, before the return.

1 / 1

One loop stays open after a save. The rows the action just inserted now have real database IDs, but the form still holds them as id-less rows, so editing and saving again would insert them a second time. To close the loop, the action returns the canonical line list with the persisted IDs, and the form swaps its whole array for that list with replace:

app/invoices/new-invoice-form.tsx
const onSubmit = async (values: Invoice) => {
const result = await createInvoice(values);
if (result.ok) {
replace(result.data.lineItems);
return;
}
applyServerErrors(form, result);
};

replace swaps the entire array at once, cheaper and more targeted than a full form.reset() when only the lines changed, and it stamps the inserted rows with their new ids. The loop closes: the user appended an id-less row, the action inserted it and returned it with an id, replace writes that id back, and the next save sees the id and UPDATEs instead of inserting a duplicate.

This is the one place the action returns more than { id }. The Result lesson discipline is that success returns the minimal ok({ id }) and the client re-reads through the revalidated cache. But replace can only reconcile the form with the persisted line IDs, which a bare { id } would not carry, so the action returns the line list instead, a deliberate, narrow exception, not a license to return full rows everywhere.

applyServerErrors(form, result) on the failure branch is the helper from the resolver lesson, reused as-is, and it works for array paths too. The one subtlety: a server-pushed error on a line must be keyed in RHF’s dotted-path shape, lineItems.0.amount, so setError lands it on the row’s Controller and the existing <FieldError> renders it. Match that shape and a business-rule failure the client couldn’t check, say a line referencing an archived product, surfaces on the exact row through the same error UI as everything else.

Order the steps the save takes, from the form's submit to the form catching back up with the database. Drag the items into the correct order, then press Check.

onSubmit hands the typed values to createInvoice(values)
The action runs InvoiceSchema.safeParse(input) first
Load the line IDs currently in the database for this invoice
Diff the submitted rows into insert / update / delete by their id
Apply all three writes inside one db.transaction
revalidateTag the invoices, then return the Result with persisted line IDs
The form calls replace with the returned rows, stamping the new ids

useFieldArray scales well: editing one row re-renders that row, not the other forty-nine, so a fifty-row invoice stays smooth.

The mental model to keep: useFieldArray owns the rows’ identity and ordering, not their values. The values live in the same form instance as always, read with useWatch and written with register and Controller. The fields array is a keyed render snapshot, so field.id is its render key, distinct from the line’s domain id that decides insert versus update on the server. Hold those two ids apart and the pattern stays clear.

Next comes the chapter’s last production pattern: carrying one form’s state across many components for a multi-step wizard.

The lesson stands on its own; these fill in the corners, the full operation reference and the drag-to-reorder example.