useFieldArray が向く場面
固定フィールドと動的フィールドの境界は「フィールド数が実行時に変わるか」で判断します。
固定フィールド → register("name") / register("email") で十分
動的フィールド → useFieldArray で管理する
例: タグ入力、複数メールアドレス、明細行、参加者リスト
import { useForm, useFieldArray } from "react-hook-form";
type FormValues = {
items: { name: string; quantity: number }[];
};
function OrderForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>({
defaultValues: { items: [{ name: "", quantity: 1 }] }, // 初期行を 1 件用意
});
const { fields, append, remove, swap, move } = useFieldArray({
control,
name: "items",
});
function onSubmit(data: FormValues) {
console.log(data.items); // [{ name: "...", quantity: 1 }, ...]
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}> {/* ✅ field.id を key に使う */}
<input {...register(`items.${index}.name`, { required: "必須" })} />
<input
type="number"
{...register(`items.${index}.quantity`, { min: 1 })}
/>
<button type="button" onClick={() => remove(index)}>削除</button>
{errors.items?.[index]?.name && (
<p>{errors.items[index].name?.message}</p>
)}
</div>
))}
<button type="button" onClick={() => append({ name: "", quantity: 1 })}>
追加
</button>
<button type="submit">送信</button>
</form>
);
}
append / remove / swap / move の使い分け
const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
control,
name: "items",
});
// append — 末尾に追加
append({ name: "", quantity: 1 });
// prepend — 先頭に追加
prepend({ name: "", quantity: 1 });
// remove — インデックス指定で削除(複数可)
remove(0);
remove([0, 2]); // 複数削除
// swap — 2 つの要素の位置を入れ替え
swap(0, 1);
// move — 要素を別のインデックスへ移動
move(2, 0); // index 2 → index 0
// insert — 指定位置に挿入
insert(1, { name: "", quantity: 1 });
field.id を key に使う理由
// ❌ index を key に使う
{fields.map((field, index) => (
<div key={index}> {/* 削除・並び替えでキーが再利用され、入力値がズレる */}
<input {...register(`items.${index}.name`)} />
</div>
))}
// ✅ field.id を key に使う
{fields.map((field, index) => (
<div key={field.id}> {/* useFieldArray が生成する安定した一意 ID */}
<input {...register(`items.${index}.name`)} />
</div>
))}
field.id は useFieldArray が内部で生成する UUID です。remove(0) や swap(0, 1) を行っても各要素の id は変わらないため、React が正しいコンポーネントと DOM ノードを対応付けられます。
index を key に使うと、削除後に残った要素のキーが繰り上がり、フォーカスや入力値が意図しない行に残るバグが発生します。
defaultValues と空配列初期化
// ✅ defaultValues で配列フィールドを初期化する
const { register, control } = useForm<FormValues>({
defaultValues: {
items: [], // 空でもよいが、型を明確にする
},
});
// ✅ 初期行を 1 件セットしたい場合
const { register, control } = useForm<FormValues>({
defaultValues: {
items: [{ name: "", quantity: 1 }],
},
});
// ❌ defaultValues を省略すると errors.items が undefined になりやすい
const { register, control } = useForm<FormValues>();
// → errors.items?.[0]?.name のアクセスは undefined で詰まらないが、
// reset() 時に配列が [] に戻らず残る場合がある
配列要素ごとのバリデーション
type FormValues = {
emails: { address: string }[];
};
function EmailListForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>({
defaultValues: { emails: [{ address: "" }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "emails" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`emails.${index}.address`, {
required: "メールアドレスは必須です",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "正しい形式で入力してください",
},
})}
/>
{/* ✅ errors.emails?.[index]?.address でアクセス */}
{errors.emails?.[index]?.address && (
<p className="text-red-500">{errors.emails[index].address?.message}</p>
)}
<button type="button" onClick={() => remove(index)}>削除</button>
</div>
))}
<button type="button" onClick={() => append({ address: "" })}>追加</button>
<button type="submit">送信</button>
</form>
);
}
Zod スキーマと組み合わせる場合:
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
emails: z
.array(
z.object({
address: z.string().email("正しい形式で入力してください"),
})
)
.min(1, "1件以上入力してください"),
});
type FormValues = z.infer<typeof schema>;
const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { emails: [{ address: "" }] },
});
// errors.emails?.root?.message で配列レベルのエラーを参照
// errors.emails?.[index]?.address?.message で要素レベルのエラーを参照
submit 時の内部値の扱い
// useFieldArray が管理する fields には内部 id プロパティが含まれる
// fields: [{ id: "abc123", name: "商品A", quantity: 1 }, ...]
// ^ useFieldArray が付与する内部プロパティ
// handleSubmit(onSubmit) に渡される data には id は含まれない(register した値のみ)
function onSubmit(data: FormValues) {
// data.items = [{ name: "商品A", quantity: 1 }] ← id は除外済み
await api.createOrder(data);
}
// ただし、手動で getValues() して送る場合は不要フィールドを除外する
const rawValues = getValues();
const cleaned = {
items: rawValues.items.map(({ name, quantity }) => ({ name, quantity })),
};
store との境界
// ❌ onChange のたびに store に反映する
function OrderForm() {
const setItems = useOrderStore((s) => s.setItems);
const { register, control, watch } = useForm<FormValues>({ ... });
const { fields } = useFieldArray({ control, name: "items" });
// watch で変化を検知して store に反映 → キーストロークごとに store 更新
const items = watch("items");
useEffect(() => { setItems(items); }, [items]);
}
// ✅ submit 後にだけ store に移す
function OrderForm() {
const setConfirmedItems = useOrderStore((s) => s.setConfirmedItems);
const { register, control, handleSubmit } = useForm<FormValues>({ ... });
const { fields, append, remove } = useFieldArray({ control, name: "items" });
function onSubmit(data: FormValues) {
setConfirmedItems(data.items); // submit 完了後にだけ store へ
}
}
よくある誤用まとめ
| 誤用 | 正しい判断 |
|---|---|
key={index} を使う | key={field.id} を使う |
defaultValues を省略する | 配列フィールドは defaultValues: { items: [] } で初期化 |
| input の値を onChange で store に流す | submit 後にだけ store に移す |
fields[index].name で値を参照する | register(\items.$.name`)で登録した値はhandleSubmit` 経由で取得 |
| 並び替えを自前の state 操作で実装する | swap / move を使う |
バリデーションエラーを errors.items[index] で直接参照する | errors.items?.[index]?.fieldName でオプショナルチェーン |
フォーム内部状態と共有状態の境界 → Zustand と react-hook-form の境界
サーバー側バリデーションとの接続 → Server Actions + Zod バリデーションパターン