請求書の明細、見積の項目、複数連絡先、そして申込の複数ステップ——業務システムのフォームは「固定の入力欄」では済みません。この記事は React Hook Form useFieldArray 公式 と trigger を一次情報に、動的フォームと複数ステップウィザードを本番品質で作る方法を掘り下げます。RHF の基礎は React Hook Form 完全ガイド、検証は Zod 4 実践ガイド を前提とします。
検証した版(2026年6月時点):
react-hook-formv7 系、@hookform/resolversv5 系、zodv4、React 19。
0. 動的フォームの2大形態
| 形態 | 例 | 道具 | 最大の落とし穴 |
|---|---|---|---|
| 可変長の繰り返し | 請求明細・タグ・連絡先 | useFieldArray | key={index} で行が壊れる |
| 複数ステップ | 申込ウィザード・オンボーディング | 1つの useForm + trigger | ステップごとに 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つの鉄則(公式由来):
keyにはfield.id。indexを使うと、削除や並べ替えで React の差分が狂い、別の行の値が表示される事故が起きます。field.idは RHF が振る安定 ID。append/prepend/insertには完全な値を。append({})は不可。全プロパティを埋めた値を渡す。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>
);
}
最後の行は消せないように:
removeをdisabled={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で最初のエラー項目へフォーカスします。- 最終
handleSubmitでresolverが全体を最終検証するため、ステップ検証をすり抜けた不整合も最後に弾けます(多重防御)。 - 各ステップ(
AccountStep等)はuseFormContextでcontrol/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 })とhandleSubmitのshouldFocusError(既定 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 を使う |
| クロス行エラーを全体に出すだけ | どの行が悪いか分からない | superRefine の path で行指定 |
| 下書きを無検証で復元 | 壊れたデータでクラッシュ | 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. shouldUnregister を true にしていないか確認を。既定の false なら非表示ステップの値も保持されます。
Q. 配列全体のエラー(最小行数)はどこに出る?
A. errors.items.root?.message または errors.items?.message に出ます。テーブルの近くに role="alert" で表示してください。
まとめ:動的フォームは「状態を1か所に、表示を動的に」
動的フォームとウィザードは、原則さえ掴めば怖くありません——
- 可変長は
useFieldArray。key={field.id}・完全な値・ドット記法の3鉄則。 - 集計は
useWatchを末端に。 フォーム全体の再描画を断つ。 - 行をまたぐ検証は
superRefineのpathで、該当行に正確にエラーを出す。 - ウィザードは1つの
useFormで全ステップを保持し、triggerで部分検証。shouldUnregister: falseで値を守る。 - 下書きは検証してから復元し、機微情報は永続化しない。
これらは「動く」だけでなく、入力者の負担を減らし(途中保存・的確な指摘)、データの整合性をコードで保証し、保守しやすいフォームを作るための設計です。明細・承認・申込のような業務の核となるフォームこそ、ここの作り込みが効きます。
請求・見積・申込など複雑な動的フォームや、長いウィザードのUX・整合性設計が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS の明細・承認フォームを、型安全・整合性・ユーザビリティを重視して設計・実装した過程を紹介しています。