# React Hook Form useFieldArray practical guide [latest 2026] — dynamic line items, nested arrays, multi-step wizards

> From variable-length line items (billing, quotes) to multi-step wizards, a practical guide to building dynamic forms at production quality with React Hook Form. It explains in real code the correct use of useFieldArray (the field.id key, complete values), nested arrays, Zod's array and cross-row validation, a wizard design that holds all steps in one useForm and partially validates per step with trigger, state persistence, a11y, and testing.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: React, React Hook Form, Zod, TypeScript, Next.js, フォーム, 型安全, フロントエンド
- URL: https://tomodahinata.com/en/blog/react-hook-form-usefieldarray-dynamic-multi-step-wizard-guide
- Category: React forms
- Pillar guide: https://tomodahinata.com/en/blog/react-hook-form

## Key points

- Variable-length line items are useFieldArray. The key must be field.id, and pass a complete value, not a partial one, to append.
- For aggregations like line-item totals, push useWatch down to the leaf and avoid re-rendering the whole form on every input.
- Array validation is Zod's z.array().min() and cross-row superRefine (output the error per row by specifying the path).
- For a multi-step wizard, 'hold all steps in one useForm' is the correct answer. On step movement, validate only that step with trigger.
- With shouldUnregister: false, hold the values of hidden steps, and give reload resistance with watch + localStorage.

---

Invoice line items, quote items, multiple contacts, and an application's multiple steps — a business system's form isn't done with "fixed input fields." This article, with [React Hook Form `useFieldArray` official](https://react-hook-form.com/docs/usefieldarray) and [`trigger`](https://react-hook-form.com/docs/useform/trigger) as primary sources, digs into how to build **dynamic forms and multi-step wizards** at production quality. RHF's basics presuppose the [React Hook Form complete guide](/blog/react-hook-form), and validation the [Zod 4 practical guide](/blog/zod).

> **The versions verified (as of June 2026):** `react-hook-form` v7 family, `@hookform/resolvers` v5 family, `zod` v4, React 19.

---

## 0. The two major forms of dynamic forms

| Form | Example | Tool | The biggest pitfall |
| --- | --- | --- | --- |
| Variable-length repetition | invoice line items, tags, contacts | `useFieldArray` | rows break with `key={index}` |
| Multiple steps | application wizard, onboarding | one `useForm` + `trigger` | splitting `useForm` per step and losing values |

These two can also be mixed (a line-item step inside a wizard). For both, the common design principle is to **"hold the state in one place and make only the display dynamic."**

---

## 1. The basics of `useFieldArray` (3 iron rules)

```tsx
import { useForm, useFieldArray } from "react-hook-form";

function Demo() {
  const { control, register } = useForm<{ items: { name: string; price: number }[] }>({
    defaultValues: { items: [{ name: "", price: 0 }] },
  });
  const { fields, append, remove, move } = useFieldArray({ control, name: "items" });

  return (
    <>
      {fields.map((field, index) => (
        <div key={field.id}> {/* 鉄則1：key は field.id（index 不可） */}
          <input {...register(`items.${index}.name`)} />
          <input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>削除</button>
        </div>
      ))}
      {/* 鉄則2：append には「完全な値」を渡す（append({}) は不可） */}
      <button type="button" onClick={() => append({ name: "", price: 0 })}>行を追加</button>
    </>
  );
}
```

**The 3 iron rules (derived from the official):**

1. **`key` must be `field.id`.** Using `index` makes React's diff go wrong on deletion or reordering, causing the accident of another row's value being displayed. `field.id` is the stable ID RHF assigns.
2. **`append` / `prepend` / `insert` take a complete value.** `append({})` is not allowed. Pass a value with all properties filled.
3. **`register`'s dot notation.** `items.0.name` is ✅, `items[0].name` is ❌.

The operation APIs: `append` / `prepend` / `insert` / `remove` / `move` (reorder) / `swap` / `update` / `replace`.

---

## 2. An invoice line-item form (a B2B SaaS typical)

Add/delete/reorder rows, and **the real-time display of the total.** For the total, as in the §re-render-optimization principle, push `useWatch` down to the leaf and avoid the parent's re-render.

```tsx
"use client";
import { useForm, useFieldArray, useWatch, type Control } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const lineItem = z.object({
  name: z.string().min(1, { error: "品名を入力してください" }),
  qty: z.number({ error: "数量は数値で" }).int().positive({ error: "1以上で" }),
  unitPrice: z.number({ error: "単価は数値で" }).nonnegative(),
});
const invoiceSchema = z.object({
  items: z.array(lineItem).min(1, { error: "明細を1行以上追加してください" }),
});
type Invoice = z.infer<typeof invoiceSchema>;

// 合計だけを購読する末端コンポーネント（親フォームは再描画されない）
function GrandTotal({ control }: { control: Control<Invoice> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((s, i) => s + (i.qty ?? 0) * (i.unitPrice ?? 0), 0);
  return <output className="text-lg font-bold">{total.toLocaleString()} 円</output>;
}

export function InvoiceForm() {
  const form = useForm<Invoice>({
    resolver: zodResolver(invoiceSchema),
    defaultValues: { items: [{ name: "", qty: 1, unitPrice: 0 }] },
    mode: "onTouched",
  });
  const { register, control, handleSubmit, formState: { errors, isSubmitting } } = form;
  const { fields, append, remove, move } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))} noValidate className="space-y-4">
      <table>
        <thead>
          <tr><th>品名</th><th>数量</th><th>単価</th><th></th></tr>
        </thead>
        <tbody>
          {fields.map((field, index) => (
            <tr key={field.id}>
              <td>
                <input
                  {...register(`items.${index}.name`)}
                  aria-invalid={!!errors.items?.[index]?.name}
                  aria-label={`明細${index + 1}の品名`}
                />
              </td>
              <td>
                <input
                  type="number"
                  {...register(`items.${index}.qty`, { valueAsNumber: true })}
                  aria-label={`明細${index + 1}の数量`}
                />
              </td>
              <td>
                <input
                  type="number"
                  {...register(`items.${index}.unitPrice`, { valueAsNumber: true })}
                  aria-label={`明細${index + 1}の単価`}
                />
              </td>
              <td>
                <button type="button" onClick={() => move(index, index - 1)} disabled={index === 0}>↑</button>
                <button type="button" onClick={() => remove(index)} disabled={fields.length === 1}>削除</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      {/* 配列全体のエラー（最小行数など）は root に出る */}
      {errors.items?.root && <p role="alert">{errors.items.root.message}</p>}
      {typeof errors.items?.message === "string" && <p role="alert">{errors.items.message}</p>}

      <button type="button" onClick={() => append({ name: "", qty: 1, unitPrice: 0 })}>明細を追加</button>
      <div>合計：<GrandTotal control={control} /></div>
      <button disabled={isSubmitting}>{isSubmitting ? "保存中…" : "保存"}</button>
    </form>
  );
}
```

> **Make the last row undeletable:** guarding `remove` with `disabled={fields.length === 1}` prevents "0 line items" on the UI side too (the server side is the final defense with Zod's `.min(1)`). Multiple defense is the basics of reliability design.

The reason the parent isn't re-rendered even as the total or row count grows, and the virtualization of many line items, see the [React Hook Form performance-optimization guide](/blog/react-hook-form-performance-rerender-optimization-guide).

---

## 3. Nested arrays and cross-row validation (Zod)

A nesting like "further option rows inside a line item" can also be written straightforwardly with dot notation. What's hard is **cross-row validation** (e.g., the total doesn't exceed the budget, no duplicate item names). With Zod's `superRefine`, output the error **on the relevant row.**

```ts
const invoiceSchema = z
  .object({
    budget: z.number().nonnegative(),
    items: z.array(lineItem).min(1, { error: "明細を1行以上追加してください" }),
  })
  .superRefine((data, ctx) => {
    // 合計が予算を超えていないか（フォーム全体のエラー）
    const total = data.items.reduce((s, i) => s + i.qty * i.unitPrice, 0);
    if (total > data.budget) {
      ctx.addIssue({
        code: "custom",
        message: `合計 ${total.toLocaleString()} 円が予算を超えています`,
        path: ["items"], // 配列全体に紐づける
      });
    }
    // 品名の重複を、重複した行に出す
    const seen = new Map<string, number>();
    data.items.forEach((item, index) => {
      if (seen.has(item.name)) {
        ctx.addIssue({
          code: "custom",
          message: "品名が重複しています",
          path: ["items", index, "name"], // ✅ その行のそのフィールドへ
        });
      }
      seen.set(item.name, index);
    });
  });
```

Specifying **a path including the array index** like `path: ["items", index, "name"]` lets you read it on the RHF side as `errors.items[index].name` and flow it straight to the relevant input's `aria-invalid`. For the design of cross-field validation, also see the [Zod 4 practical guide](/blog/zod).

---

## 4. Multi-step wizard: the correct answer is "one useForm"

The most common failure in a wizard is **splitting `useForm` per step and losing the previous step's values.** The correct answer is simple —

> **Hold the whole form in one `useForm` and switch only the displayed step. When advancing a step, validate only that step's fields with `trigger`.**

```tsx
"use client";
import { useState } from "react";
import { useForm, FormProvider, type Path } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

// ステップ定義：各ステップが検証すべきフィールド名を持つ
const STEPS = [
  { id: "account", title: "アカウント", fields: ["email", "password"] },
  { id: "profile", title: "プロフィール", fields: ["name", "company"] },
  { id: "items", title: "明細", fields: ["items"] },
] as const;

export function SignupWizard() {
  const [step, setStep] = useState(0);
  const form = useForm<Wizard>({
    resolver: zodResolver(wizardSchema),
    defaultValues: loadDraft() ?? emptyDraft,
    mode: "onTouched",
    shouldUnregister: false, // 非表示ステップの値を保持する（重要）
  });

  const isLast = step === STEPS.length - 1;

  const next = async () => {
    // 当該ステップのフィールドだけ検証。通れば進む（shouldFocus で最初のエラーへ）
    const ok = await form.trigger(STEPS[step].fields as Path<Wizard>[], { shouldFocus: true });
    if (ok) setStep((s) => Math.min(s + 1, STEPS.length - 1));
  };
  const back = () => setStep((s) => Math.max(s - 1, 0));
  const onSubmit = async (data: Wizard) => {
    await api.signup(data); // 最終送信。resolver が全体を最終検証してから到達する
    clearDraft();
  };

  return (
    <FormProvider {...form}>
      {/* 進捗表示（a11y） */}
      <ol aria-label="進捗">
        {STEPS.map((s, i) => (
          <li key={s.id} aria-current={i === step ? "step" : undefined}>{s.title}</li>
        ))}
      </ol>

      <form onSubmit={form.handleSubmit(onSubmit)} noValidate>
        {/* 値は RHF が保持するので、非表示ステップを unmount しても失われない */}
        {step === 0 && <AccountStep />}
        {step === 1 && <ProfileStep />}
        {step === 2 && <ItemsStep />}

        <nav className="mt-6 flex gap-2">
          {step > 0 && <button type="button" onClick={back}>戻る</button>}
          {!isLast ? (
            <button type="button" onClick={next}>次へ</button>
          ) : (
            <button type="submit" disabled={form.formState.isSubmitting}>送信</button>
          )}
        </nav>
      </form>
    </FormProvider>
  );
}
```

The design crux:

- Since **`shouldUnregister: false` (the default)** works, even if you `unmount` a step, the value remains in the RHF state. So values don't disappear between steps.
- **`trigger(fields)`** validates only the specified fields and returns a `boolean`. `shouldFocus: true` focuses the first error item.
- In the final **`handleSubmit`,** the `resolver` **finally validates the whole,** so an inconsistency that slipped past the step validation can also be rejected at the end (multiple defense).
- Each step (`AccountStep`, etc.) takes `control`/`register` with `useFormContext` and just lines up the fields of §1–§3.

---

## 5. Reload resistance: persisting the draft

A long wizard is fatal if it disappears on a reload or a mistaken operation. With `watch`'s callback subscription, **save the draft** and restore it at startup.

```ts
import { useEffect } from "react";

const DRAFT_KEY = "signup-wizard-draft";

// 保存：watch のコールバック版はレンダリングを起こさない
function usePersistDraft(form: UseFormReturn<Wizard>) {
  useEffect(() => {
    const sub = form.watch((values) => {
      try {
        localStorage.setItem(DRAFT_KEY, JSON.stringify(values));
      } catch {
        /* ストレージ満杯等は黙ってスキップ（致命ではない） */
      }
    });
    return () => sub.unsubscribe(); // 必ず購読解除
  }, [form]);
}

// 復元：必ず Zod で検証してから初期値に使う（壊れた下書きを信用しない）
function loadDraft(): Wizard | null {
  try {
    const raw = localStorage.getItem(DRAFT_KEY);
    if (!raw) return null;
    const parsed = wizardSchema.partial().safeParse(JSON.parse(raw));
    return parsed.success ? (parsed.data as Wizard) : null;
  } catch {
    return null;
  }
}
```

> **Security note:** **don't save a password or sensitive information** in `localStorage`. Limit the draft target to items painful to re-enter, like name, company name, and line items, and exclude sensitive steps. Making the persistence destination a URL query makes it strong for sharing and back operations, but likewise don't put sensitive information on it.

---

## 6. Accessibility

- **Progress:** convey the current step to assistive technology with `aria-current="step"`.
- **Focus on error:** auto-focus the first error with `trigger(..., { shouldFocus: true })` and `handleSubmit`'s `shouldFocusError` (default true).
- **Row operations:** attach an `aria-label` (e.g., "the unit price of line item 2") to each line-item input, and make the meaning of the add/delete buttons clear. After deletion, move focus to the next valid element.
- **Notify dynamic additions:** lightly notifying row additions/deletions in an `aria-live="polite"` region conveys the change to screen-reader users too.

For the overall picture of form a11y, see the [React/Next.js accessibility implementation guide](/blog/react-nextjs-web-accessibility-wcag22-guide).

---

## 7. Testing

```tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { InvoiceForm } from "./invoice-form";

describe("InvoiceForm", () => {
  it("行を追加・削除できる", async () => {
    render(<InvoiceForm />);
    await userEvent.click(screen.getByRole("button", { name: "明細を追加" }));
    expect(screen.getAllByLabelText(/の品名$/)).toHaveLength(2);
  });

  it("合計が入力に追従する", async () => {
    render(<InvoiceForm />);
    await userEvent.type(screen.getByLabelText("明細1の数量"), "3");
    await userEvent.type(screen.getByLabelText("明細1の単価"), "1000");
    await waitFor(() => expect(screen.getByText(/3,000 円/)).toBeInTheDocument());
  });
});
```

For a wizard, verify "you can't advance if a step is unfilled" and "you can advance to the end and submit" by user operation. For E2E, see the [Playwright E2E practical guide](/blog/playwright-e2e-testing-production-design-guide).

---

## 8. Anti-patterns and countermeasures

| Anti-pattern | What happens | Countermeasure |
| --- | --- | --- |
| `key={index}` | another row's value appears on deletion/reordering | `key={field.id}` |
| `append({})` (partial value) | validation/display breaks with undefined fields | pass a complete value |
| splitting `useForm` per step | the previous step's values disappear | hold all in one `useForm` |
| validating all fields every time in a wizard | all errors appear mid-input | `trigger(that step's fields)` |
| hidden steps with `shouldUnregister: true` | values disappear / `useFieldArray` unsupported | use the default `false` |
| outputting cross-row errors only to the whole | you can't tell which row is bad | specify the row with `superRefine`'s `path` |
| restoring the draft without validation | crashes with broken data | use it after `safeParse` with Zod |

---

## 9. FAQ

**Q. Is it OK even if line items become hundreds of rows?**
A. It's OK, but render only the visible rows with `React.memo` + virtualization (`@tanstack/react-virtual`). Since values remain in the RHF state, even if you unmount off-screen rows, you get all rows on submit. For details, the [performance-optimization guide](/blog/react-hook-form-performance-rerender-optimization-guide).

**Q. Should I split the schema per step?**
A. First, **one big schema** (KISS). For step validation, narrowing the target fields with `trigger` suffices. For a huge form with highly-independent steps, a design that `merge`s per-step schemas is also possible, but it's enough once you need it (YAGNI).

**Q. The total display is one beat slow / heavy.**
A. Aren't you `watch`ing the total directly under `useForm`? Push `useWatch` down to the small component that displays it (§2).

**Q. Input disappears on the wizard's "back."**
A. Confirm you didn't set `shouldUnregister` to `true`. With the default `false`, the values of hidden steps are held too.

**Q. Where does the whole-array error (minimum row count) appear?**
A. It appears in `errors.items.root?.message` or `errors.items?.message`. Display it near the table with `role="alert"`.

---

## Summary: a dynamic form is "state in one place, display dynamic"

A dynamic form and a wizard aren't scary if you grasp the principles —

1. **Variable-length is `useFieldArray`.** The 3 iron rules of `key={field.id}`, a complete value, and dot notation.
2. **Aggregations push `useWatch` to the leaf.** Sever the re-render of the whole form.
3. **Cross-row validation, with `superRefine`'s `path`,** outputs the error accurately on the relevant row.
4. **A wizard holds all steps in one `useForm`** and partially validates with `trigger`. Protect values with `shouldUnregister: false`.
5. **Restore the draft after validation,** and don't persist sensitive information.

These are design for forms that not only "work" but **reduce the inputter's burden (mid-save, accurate pointing-out), guarantee data consistency with code, and are easy to maintain.** Forms that are the core of a business, like line items, approvals, and applications, are exactly where this build-out pays off.

**For complex dynamic forms like billing, quotes, and applications, or the UX/consistency design of a long wizard, please feel free to consult me.** The case study below introduces the process of designing and implementing the line-item / approval forms of a B2B SaaS that supports an industry's core operations, with an emphasis on type safety, consistency, and usability.
