# React Hook Form useFieldArray 実践ガイド【2026年最新】— 動的明細・ネスト配列・複数ステップウィザード

> 可変長の明細（請求・見積）から複数ステップのウィザードまで、React Hook Form で動的フォームを本番品質に作る実践ガイド。useFieldArray の正しい使い方（field.id キー・完全な値）、ネスト配列、Zod の配列・クロス行検証、1つの useForm で全ステップを保持しステップごとに trigger で部分検証するウィザード設計、状態永続化、a11y、テストまでを実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: React, React Hook Form, Zod, TypeScript, Next.js, フォーム, 型安全, フロントエンド
- URL: https://tomodahinata.com/blog/react-hook-form-usefieldarray-dynamic-multi-step-wizard-guide

## 要点

- 可変長の明細は useFieldArray。key は必ず field.id、append には部分でなく完全な値を渡す
- 明細合計などの集計は useWatch を末端に下ろし、入力のたびのフォーム全体再描画を避ける
- 配列の検証は Zod の z.array().min() とクロス行の superRefine（path 指定で行単位にエラーを出す）
- 複数ステップウィザードは『1つの useForm で全ステップを保持』が正解。ステップ移動時に trigger で当該ステップだけ検証する
- shouldUnregister: false で非表示ステップの値を保持し、watch + localStorage でリロード耐性を持たせる

---

請求書の明細、見積の項目、複数連絡先、そして申込の複数ステップ——業務システムのフォームは「固定の入力欄」では済みません。この記事は [React Hook Form `useFieldArray` 公式](https://react-hook-form.com/docs/usefieldarray) と [`trigger`](https://react-hook-form.com/docs/useform/trigger) を一次情報に、**動的フォームと複数ステップウィザード**を本番品質で作る方法を掘り下げます。RHF の基礎は [React Hook Form 完全ガイド](/blog/react-hook-form)、検証は [Zod 4 実践ガイド](/blog/zod) を前提とします。

> **検証した版（2026年6月時点）：** `react-hook-form` v7 系、`@hookform/resolvers` v5 系、`zod` v4、React 19。

---

## 0. 動的フォームの2大形態

| 形態 | 例 | 道具 | 最大の落とし穴 |
| --- | --- | --- | --- |
| 可変長の繰り返し | 請求明細・タグ・連絡先 | `useFieldArray` | `key={index}` で行が壊れる |
| 複数ステップ | 申込ウィザード・オンボーディング | 1つの `useForm` ＋ `trigger` | ステップごとに `useForm` を分け、値を失う |

この2つは混ざることもあります（ウィザードの中に明細ステップ）。どちらも「**状態を1か所に保ち、表示だけを動的にする**」のが共通の設計原則です。

---

## 1. `useFieldArray` の基礎（3つの鉄則）

```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>
    </>
  );
}
```

**3つの鉄則（公式由来）：**

1. **`key` には `field.id`。** `index` を使うと、削除や並べ替えで React の差分が狂い、別の行の値が表示される事故が起きます。`field.id` は RHF が振る安定 ID。
2. **`append` / `prepend` / `insert` には完全な値を。** `append({})` は不可。全プロパティを埋めた値を渡す。
3. **`register` のドット記法。** `items.0.name` は ✅、`items[0].name` は ❌。

操作 API：`append` / `prepend` / `insert` / `remove` / `move`（並べ替え）/ `swap` / `update` / `replace`。

---

## 2. 請求明細フォーム（B2B SaaS の典型）

行の追加・削除・並べ替え、そして**合計のリアルタイム表示**。合計は §再描画最適化の原則どおり、`useWatch` を末端に下ろして親の再描画を避けます。

```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>
  );
}
```

> **最後の行は消せないように：** `remove` を `disabled={fields.length === 1}` で守ると「明細0行」を UI 側でも防げます（サーバー側は Zod の `.min(1)` で最終防御）。多重防御は信頼性設計の基本です。

合計や行数が増えても親が再描画されない理由・大量明細の仮想化は [React Hook Form パフォーマンス最適化ガイド](/blog/react-hook-form-performance-rerender-optimization-guide) を参照してください。

---

## 3. ネストした配列とクロス行検証（Zod）

「明細の中にさらにオプション行」のようなネストも、ドット記法で素直に書けます。難しいのは**行をまたぐ検証**（例：合計が予算を超えない、品名の重複禁止）。Zod の `superRefine` で**該当行に**エラーを出します。

```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);
    });
  });
```

`path: ["items", index, "name"]` のように**配列インデックスを含むパス**を指定すると、RHF 側では `errors.items[index].name` として読め、該当入力の `aria-invalid` にそのまま流せます。クロスフィールド検証の設計は [Zod 4 実践ガイド](/blog/zod) も参照。

---

## 4. 複数ステップウィザード：正解は「1つの useForm」

ウィザードで最も多い失敗は、**ステップごとに `useForm` を分けて、前のステップの値を失う**ことです。正解はシンプル——

> **フォーム全体を1つの `useForm` で保持し、表示するステップだけ切り替える。ステップを進めるときは `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>
  );
}
```

設計の勘所：

- **`shouldUnregister: false`（既定）** が効くので、ステップを `unmount` しても値は RHF 状態に残ります。だからステップ間で値が消えません。
- **`trigger(fields)`** は指定フィールドだけ検証して `boolean` を返す。`shouldFocus: true` で最初のエラー項目へフォーカスします。
- **最終 `handleSubmit`** で `resolver` が**全体を最終検証**するため、ステップ検証をすり抜けた不整合も最後に弾けます（多重防御）。
- 各ステップ（`AccountStep` 等）は `useFormContext` で `control`/`register` を取り、§1〜§3 のフィールドを並べるだけ。

---

## 5. リロード耐性：下書きの永続化

長いウィザードはリロードや誤操作で消えると致命的。`watch` のコールバック購読で**下書きを保存**し、起動時に復元します。

```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;
  }
}
```

> **セキュリティ注意：** `localStorage` に**パスワードや機微情報を保存しない**こと。下書き対象は氏名・会社名・明細のような再入力が苦痛な項目に限定し、機微なステップは除外します。永続化先を URL クエリにすると共有・戻る操作に強くなりますが、同様に機微情報は載せません。

---

## 6. アクセシビリティ

- **進捗：** `aria-current="step"` で現在ステップを支援技術へ伝える。
- **エラー時フォーカス：** `trigger(..., { shouldFocus: true })` と `handleSubmit` の `shouldFocusError`（既定 true）で最初のエラーへ自動フォーカス。
- **行の操作：** 明細の各入力に `aria-label`（例「明細2の単価」）を付け、追加・削除ボタンの意味を明確に。削除後はフォーカスを次の妥当な要素へ移す。
- **動的な追加の通知：** 行追加・削除を `aria-live="polite"` 領域で軽く通知すると、スクリーンリーダー利用者にも変化が伝わる。

フォーム a11y の全体像は [React/Next.js アクセシビリティ実装ガイド](/blog/react-nextjs-web-accessibility-wcag22-guide) を参照。

---

## 7. テスト

```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());
  });
});
```

ウィザードは「各ステップで未入力だと進めない」「最後まで進めて送信できる」をユーザー操作で検証します。E2E は [Playwright E2E 実践ガイド](/blog/playwright-e2e-testing-production-design-guide) を参照。

---

## 8. アンチパターンと対処

| アンチパターン | 何が起きる | 対処 |
| --- | --- | --- |
| `key={index}` | 削除・並べ替えで別行の値が出る | `key={field.id}` |
| `append({})`（部分値） | 未定義フィールドで検証・表示が崩れる | 完全な値を渡す |
| ステップごとに `useForm` を分ける | 前ステップの値が消える | 1つの `useForm` で全保持 |
| ウィザードで全フィールドを毎回検証 | 入力途中で全エラーが出る | `trigger(当該ステップのfields)` |
| `shouldUnregister: true` で非表示ステップ | 値が消える／`useFieldArray` 非対応 | 既定の `false` を使う |
| クロス行エラーを全体に出すだけ | どの行が悪いか分からない | `superRefine` の `path` で行指定 |
| 下書きを無検証で復元 | 壊れたデータでクラッシュ | Zod で `safeParse` してから使う |

---

## 9. FAQ

**Q. 明細が数百行になっても大丈夫？**
A. 大丈夫ですが、`React.memo` ＋ 仮想化（`@tanstack/react-virtual`）で可視行だけ描画します。値は RHF 状態に残るので、画面外の行を unmount しても送信時に全行取れます。詳細は [パフォーマンス最適化ガイド](/blog/react-hook-form-performance-rerender-optimization-guide)。

**Q. ステップごとにスキーマを分けるべき？**
A. まずは**1つの大きなスキーマ**（KISS）。ステップ検証は `trigger` で対象フィールドを絞れば足ります。ステップが独立性の高い巨大フォームなら、ステップ別スキーマを `merge` する設計も可能ですが、必要になってからで十分（YAGNI）。

**Q. 合計の表示が一拍遅れる／重い。**
A. 合計を `useForm` 直下で `watch` していませんか。表示する小コンポーネントに `useWatch` を下ろしてください（§2）。

**Q. ウィザードの「戻る」で入力が消えます。**
A. `shouldUnregister` を `true` にしていないか確認を。既定の `false` なら非表示ステップの値も保持されます。

**Q. 配列全体のエラー（最小行数）はどこに出る？**
A. `errors.items.root?.message` または `errors.items?.message` に出ます。テーブルの近くに `role="alert"` で表示してください。

---

## まとめ：動的フォームは「状態を1か所に、表示を動的に」

動的フォームとウィザードは、原則さえ掴めば怖くありません——

1. **可変長は `useFieldArray`。** `key={field.id}`・完全な値・ドット記法の3鉄則。
2. **集計は `useWatch` を末端に。** フォーム全体の再描画を断つ。
3. **行をまたぐ検証は `superRefine` の `path`** で、該当行に正確にエラーを出す。
4. **ウィザードは1つの `useForm`** で全ステップを保持し、`trigger` で部分検証。`shouldUnregister: false` で値を守る。
5. **下書きは検証してから復元**し、機微情報は永続化しない。

これらは「動く」だけでなく、**入力者の負担を減らし（途中保存・的確な指摘）、データの整合性をコードで保証し、保守しやすい**フォームを作るための設計です。明細・承認・申込のような業務の核となるフォームこそ、ここの作り込みが効きます。

**請求・見積・申込など複雑な動的フォームや、長いウィザードのUX・整合性設計が必要な場合は、お気軽にご相談ください。** 下記の事例では、業界の基幹業務を支える B2B SaaS の明細・承認フォームを、型安全・整合性・ユーザビリティを重視して設計・実装した過程を紹介しています。
