メインコンテンツへスキップ
友田 陽大
Reactフォーム実装
React
React Hook Form
Zod
TypeScript
Next.js
フォーム
型安全
フロントエンド

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

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

公開日
読了時間
12分
著者
友田 陽大
シェア

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

検証した版(2026年6月時点): react-hook-form v7 系、@hookform/resolvers v5 系、zod v4、React 19。


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

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

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


1. useFieldArray の基礎(3つの鉄則)

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 を末端に下ろして親の再描画を避けます。

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

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

合計や行数が増えても親が再描画されない理由・大量明細の仮想化は React Hook Form パフォーマンス最適化ガイド を参照してください。


3. ネストした配列とクロス行検証(Zod)

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

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 実践ガイド も参照。


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

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

フォーム全体を1つの useForm で保持し、表示するステップだけ切り替える。ステップを進めるときは trigger で当該ステップのフィールドだけ検証する。

"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 で最初のエラー項目へフォーカスします。
  • 最終 handleSubmitresolver全体を最終検証するため、ステップ検証をすり抜けた不整合も最後に弾けます(多重防御)。
  • 各ステップ(AccountStep 等)は useFormContextcontrol/register を取り、§1〜§3 のフィールドを並べるだけ。

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

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

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 })handleSubmitshouldFocusError(既定 true)で最初のエラーへ自動フォーカス。
  • 行の操作: 明細の各入力に aria-label(例「明細2の単価」)を付け、追加・削除ボタンの意味を明確に。削除後はフォーカスを次の妥当な要素へ移す。
  • 動的な追加の通知: 行追加・削除を aria-live="polite" 領域で軽く通知すると、スクリーンリーダー利用者にも変化が伝わる。

フォーム a11y の全体像は React/Next.js アクセシビリティ実装ガイド を参照。


7. テスト

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 実践ガイド を参照。


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

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

9. FAQ

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

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

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

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

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


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

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

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

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

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

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る