Skip to content
Chapter 45Lesson 4

useFieldArray: dynamic lists of fields

React Hook Form's useFieldArray hook, the tool 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 has more than that. It has a list of line items, and the user decides how many: one line for a quick consultation, twelve for a month of itemized work. A form that owns a variable-length list like that is one of the four triggers from the start of this chapter, and this is the lesson where it gets its tool.

By the end, that same form grows an add-and-remove line-items section. Its total is computed from the rows, it validates each row as well as the “at least one line” rule, it reorders, and it saves through the same createInvoice action, turning the list the user edited into the right mix of database inserts, updates, and deletes. The hook that makes all of that tractable is useFieldArray, and its value is clearest once you’ve pictured doing the same job by hand.

What a variable-length list costs the native way

Section titled “What a variable-length list costs the native way”

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

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

That single map hides four separate 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 together from the row’s position. The server has to parse that string convention back into an array on the other side.
  2. A parallel useState array of row keys, the rowIds above, that exists only to give React a stable key and to drive the add and remove buttons. It holds no data the user cares about; it is pure scaffolding for the list’s identity.
  3. Manual re-indexing when a middle row is removed. Delete row 1 of three and rows 2 and 3 have to renumber to 1 and 2, or their name strings collide and the FormData you read on submit is wrong.
  4. A second parallel effort to render each row’s error. The schema validates lineItems as an array, so a bad amount on row 2 lives at the array path lineItems[2].amount. Matching that path back to the right input by hand, for every row, is its own pile of work.

That is four bookkeeping jobs for one conceptual thing: a list of rows. None of them is about the invoice; all of them are about forcing a flat, string-keyed form to hold a nested, growable structure. This is the threshold the chapter named: when one form owns a set of repeated rows whose count the user controls, the native pattern’s coordination cost is no longer worth paying, and useFieldArray is the standard tool to reach for. It owns all four jobs, the array structure, the identity keys, the re-indexing, and the per-row error wiring, behind one hook call.

One thing it does not change is the trust boundary. useFieldArray is a client-side convenience for editing the list, exactly as the resolver was a client-side convenience for validating it. The action still runs InvoiceSchema.safeParse(input) on entry, array and all. Adopting the hook makes the editing pleasant; it does not move the gate where the server decides what to trust. That line has held for every lesson in this chapter and it holds here.

The hook: fields, append, remove, and the rest

Section titled “The hook: fields, append, remove, and the rest”

useFieldArray does not create a form. It plugs into the one you already have. You hand it the control off the form object the page created back in the primitives lesson, name the array field it should manage, and it returns an array to render plus a set of operations to mutate that array. Here is the whole call:

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. There are a few more (prepend, insert, swap, update), summarized just below.

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

control is the same one off the form you already created, which is how the hook reads and writes 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. It is not the live value: to read what the user has typed right now, you use useWatch, covered a few sections down. Map over it to render rows.

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

Read the operations as a small imperative API you call from event handlers: a click adds a row, a click removes one. The names say what they do, and you reach for a handful of them:

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

The job these operations quietly absorb is the third on the painful list above: re-indexing. When you remove(1) from a three-row list, RHF slides rows up, migrates each row’s validation and dirty state to its new position, and re-keys everything internally. You never splice an array or renumber a name string. The whole category of bug where a deleted middle row leaves the form’s data misaligned is gone, because you stopped owning the array’s mechanics.

One rule from that walkthrough is worth pulling out, because it is the most common first mistake with the hook.

Rendering rows, and why the key must be field.id

Section titled “Rendering rows, and why the key must be field.id”

Rendering the list is a map over fields. Each entry becomes a row, each row renders its inputs through the Field + Controller layer this chapter standardized on, and the row carries a “Remove” button. A single “Add line” button sits below the list. Here is the section, built on the form file from the last two lessons:

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>

Everything in that snippet you have seen before: the Controller render prop, the field spread, fieldState driving the error row. The only new parts are that the field path is now templated with the row index (lineItems.${index}.description) and the row is keyed by field.id. One thing to read carefully: there are two different fields here. The outer one, from fields.map((field, index) => …), is the array row, and that is the one whose id becomes the key. The inner one, from Controller’s render={({ field }) => …}, is the per-input wiring bundle you already know. They share a name but they are different objects, and only the outer one carries the id. Getting that key wrong produces a bug that is invisible in the code and obvious the moment you click “Remove.”

Here is the trap. Reaching for key={index} looks fine: the rows render, add works, remove works. It only breaks when 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 it shifts up by one, so React sees the key at that position unchanged, keeps the old DOM node, and puts the next row’s data into it. Focus jumps, half-typed text lands on the wrong line, dirty flags migrate, and exit animations fire on the wrong node.

This is one of the oldest rules in React, and you met it as a code convention earlier: lists need a stable key tied to data identity, and never the array index for reorderable lists. It matters so much here because useFieldArray is built for the reorderable case, where adding, removing, and moving rows is the whole point, so the index key is wrong on every operation that isn’t “append to the end.” That is why field.id exists: it is a render key RHF generates for exactly this purpose. Use it, and forget the array index for this map.

Reading the explanation makes the breakage plausible; watching it makes it concrete. Scrub through the sequence below. There are three rows, A, B, and C; the user has focus and half-typed text in row C, then removes row B.

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 move to a different row. The right column differs by one character. That is what field.id buys you.

Pin down the two characters that make this list correct. The snippet below is a minimal line-items list with useFieldArray already wired: plain <input>s, no resolver, and no Field layer. That is the real lesson’s shape, simplified here so the keying point stands alone. Two spots are left blank: the row’s key, and the argument to append. Get both right and add, remove, and reorder are correct; get the key wrong and a removed middle row silently moves the survivor’s text and focus.

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. Following the discipline this chapter installed, one schema with two importers, that shape lives in exactly one place: the feature’s invoice-schema.ts, imported by both the action and the form. So extending the form to hold line items starts by extending the schema, and everything else follows from 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 in that block carry weight. The format builders follow the Zod 4 conventions you have been using: top-level z.uuid(), not the deprecated string chain. The amount uses z.coerce.number<number>(), the same generic-pinned coercion from the resolver lesson: it accepts the string an <input> produces, produces a number, and the pinned generic keeps the input type a clean number so defaultValues and the registered field type-check. And the .min(1, 'Add at least one line') on the array is the “you can’t ship an empty invoice” rule.

The one to notice is id: z.uuid().optional(). This is not the same as field.id. This is the line item’s domain ID, the primary key it has in the database, if it has one. A row the user just added has no domain ID yet (undefined); a row loaded from an existing invoice carries the one the database assigned. That present-or-absent distinction is the hinge for saving the array later: present means “update this existing line,” absent means “insert this new one.” Hold on to that; it pays off in the final section.

Now the part the chapter outline flagged as the lesson’s second pitfall: where the errors land. A field array produces errors at two genuinely different paths, and beginners reliably look for both in the wrong place.

The first is the per-row error. A bad amount on row 2 lands at errors.lineItems[2].amount. You almost never read that raw path, though, because each row renders through a Controller scoped to lineItems.${index}.amount, and the fieldState the render prop hands you is already the error for that exact row. So <FieldError errors={[fieldState.error]} /> inside the row renders it with no path arithmetic, the same wiring you have used for every field this chapter. You just need to recognize the shape when you see it in formState.errors.

The second is the array-level error, the .min(1, 'Add at least one line') one. This is the trap. That error does not attach to any row, so it never shows up inside the Controllers. And it is not at errors.lineItems.message, which is where most people look second. It lives at errors.lineItems.root.message:

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

The root error slot is where RHF puts errors that belong to the array as a whole rather than to any one element. Render it once, as the list’s own error row, usually right below “Add line,” so an empty list shows “Add at least one line” exactly where the user would go to fix it.

Both of these are the resolver’s client-side reads of the same schema the action parses with. The rules, the per-row .min(1), the array .min(1), and the messages, are written once in the schema file, and both sides read them. You are not maintaining two copies of “an invoice needs at least one line.”

A quick recall check on the two paths, since prose glosses over the exact characters and these are the characters that catch people out. Fill in the blanks.

Fill the blanks. The first read is the per-row field error; the second is the array-level 'add at least one line' error. They land in different places. Pick the right option from each dropdown, then press Check.

const rowError =
form.formState.errors.lineItems?.___?.amount?.message;
const listError =
form.formState.errors.lineItems?.___?.___;

The form’s total was a field the user typed. On a real invoice it shouldn’t be: the total is the sum of the line amounts, and asking the user to keep it in sync with the lines they are editing is asking for a wrong number. So the better design is to derive it: make total read-only and computed from the rows, updating live as the user edits amounts. That requires reading the array’s current values, which is where the snapshot caveat from earlier comes due.

Recall that fields is a render-time snapshot, not the live value. Mapping over it renders the rows, but its amounts are frozen at the last render, which is useless for a running sum. To read what the user has typed right now, you have two tools, and the choice comes down to whether you need to react to changes:

  • form.getValues('lineItems') reads the current array once, imperatively, and does not subscribe, so there is no re-render when it changes. 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 any amount changes. Right for a live-updating total.

For the total you want the subscription. But recall the lever from the primitives lesson: a subscription in the form root re-renders the entire form on every keystroke. So you scope it, dropping the useWatch into a small leaf component that renders only the total, exactly as the CharCount example did. The form root stays still; only the total updates.

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 and not in the form root, only this component re-renders when an amount changes, and the form stays still. It is the same scope-the-subscription move from the primitives lesson, applied to the array.

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 defensive Number(line.amount) || 0 matters because the watched value is the input shape, so an amount mid-edit can be the empty string '' (or NaN after Number). The || 0 keeps the running total a number while the user types; the schema’s coercion is what 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>;
};

Render it read-only. <output> is the semantic element for a calculated result, and there is no input here for the user to edit: the total is a consequence of the lines, not a field.

1 / 1

You drop <InvoiceTotal control={form.control} /> into the form wherever the total should display, and you delete the old total input entirely. The watched value being the input type is the same z.input versus z.output distinction from the resolver lesson, surfacing one more time: RHF tracks what goes in (a string mid-edit), and the schema produces what comes out (a coerced number on submit). The leaf reads the input side, so it guards; the action receives the output side, already clean. The form tracks the input, the schema transforms it, the action receives the output, and the total just happens to be reading the input end.

This is the same useWatch-in-a-leaf isolation the primitives lesson made literal with render badges, so the mechanism is unchanged; what is new is a concrete reason to reach for it. Scope the subscription rather than memoizing the tree.

Sometimes the order of the rows matters: the line order prints on the invoice, or rows carry a priority. move(from, to) handles it. It slides a row to a new index and re-indexes everyone else, migrating each row’s field state along with it. You don’t splice the array, and because your key is field.id and not the index, the surviving rows keep their identity, so a reorder animates cleanly instead of flickering. This is the keying section paying off a second time: field.id makes both removal and reordering correct.

The simplest approach for occasionally reordering a handful of rows needs no new dependency, just two buttons per row:

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. That is the whole feature for a list a user reorders now and then.

When the product genuinely needs drag-to-reorder, a long list the user rearranges constantly, the integration point is the same. You pair move with a drag-and-drop library: the library fires a callback when a drag ends, handing you the source and target indices, and your handler is one line, move(from, to). RHF re-renders the new order and you are done. The 2026 choice for the library itself is @dnd-kit; you don’t need its tutorial to see the integration point, which is just onDragEnd → move. The reference below has the full drag example when you reach for it.

Saving the array: the insert, update, delete diff

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

The form now edits a list. 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 framing is this: the form owns the row set the user wants, and the action reconciles that set against what is currently in 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: RHF holds the typed object, so onSubmit(values) hands it straight to await createInvoice(values), and the action’s first line is still InvoiceSchema.safeParse(input). (The chapter’s project keeps FormData because it is deliberately built on the native pattern; this RHF form is the typed-object caller. That decision was settled last lesson.)

What’s new is the action’s body. Once it has parsed the array, it computes a three-way diff by looking at 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, always: the same gate from every lesson. The array crossed the wire, so it gets the same safeParse treatment as every other field. Bail on failure 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 IDs the form submitted into a set. These two collections are what the diff compares: current truth versus 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 three-way split, all 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). In the database but absent from the submission means DELETE (a row the user removed). The remove(index) clicks become deletes here.

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 (the transaction mechanics are from the Drizzle chapter). Revalidate after the write, before the return. External calls would sit outside the transaction; there are none here.

1 / 1

Read the three-way split slowly, because it is the idea the whole id? field was for. Every row the user added with append has no id, so it is an INSERT. Every row that came from an existing invoice has its id, so it is an UPDATE. And every row that was in the database but is missing from what the form submitted is one the user deleted with remove, so it is a DELETE, found by set difference. The form never sends a “delete this” instruction; deletion is inferred from absence. That is why the submitted list has to be the complete, current desired state: the action trusts it as the full picture.

There is a loop to close after a successful save. The rows the action just inserted now have real database IDs, but the form still holds them as id-less rows, because that is how the user left them. If the user edits and saves again, those rows would look new a second time and get inserted twice. The fix is to reconcile the form to server truth: the action returns the canonical line list with the persisted IDs, and the form swaps its whole array for that list in one call 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, which is cheaper and more targeted than a full form.reset() when only the lines changed, and it stamps the inserted rows with their new ids. So 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 into the form, and the next save sees a row with an id and correctly UPDATEs it instead of inserting a duplicate. The form and the database stay in agreement across repeated saves.

This is the one place the action returns more than { id }. The Server Action discipline from the Result lesson is that success returns the minimal payload, ok({ id }), and the client re-reads through the revalidated cache. Here the action returns the canonical line list instead, because replace can only reconcile the form if it gets the persisted line IDs back; a bare { id } would not carry them. It is a deliberate, narrow exception to the minimal-payload rule, not a license to start returning full rows everywhere.

That applyServerErrors(form, result) on the failure branch is the helper from the resolver lesson, reused as-is. It routes any fieldErrors the action returns back onto the right fields through setError, and it works for array paths too. The one wiring subtlety is that a server-pushed error on a line has to be keyed in RHF’s dotted-path shape, lineItems.0.amount, so that setError lands it on the row’s registered Controller and the existing <FieldError> renders it. Match that path 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.

The save lifecycle has a precise order, and getting it out of order is the bug. Put these steps in sequence.

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 further than you would guess, thanks to the same isolation that powers the rest of RHF: editing one row re-renders that row, not the other forty-nine. A fifty-row invoice stays smooth. But there is a ceiling, and it is worth knowing where it sits so you don’t push useFieldArray past what it is built for.

The invoice form that started this chapter with three scalar fields now owns a real line-items list. You added a lineItems array to the one shared schema; useFieldArray gave you append, remove, and move over it without a single hand-spliced array or hand-numbered name string; key={field.id} made add, remove, and reorder correct where the array index would have silently corrupted them; the two error paths, per-row through Controller and array-level at errors.lineItems.root, render through the same <FieldError> rows as every other field; a scoped useWatch in an <InvoiceTotal> leaf derives the total live without churning the form; and the action turns the submitted list into the right inserts, updates, and deletes by diffing on the domain id, with replace closing the loop so repeated saves stay correct.

The one mental model to keep: useFieldArray owns the rows’ identity and ordering, not their values. The values live in the same form instance you have had all along, read with useWatch and written with register and Controller. The fields array is a keyed render snapshot, and 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 whole pattern stays clear.

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

The lesson is self-contained; these fill in the corners: the full operation reference and the drag-to-reorder example named above.