TOOLS BOX/ガイド/react-hook-form の field array パターン
Concept

react-hook-form の field array パターン

react-hook-form の useFieldArray を使った動的フォームの設計判断をまとめるガイド。固定フィールドとの境界、append / remove / swap / move の使い方、field.id を key に使う理由、defaultValues の初期化、配列要素ごとのバリデーション、submit データの整形、よくある誤用を実務目線で整理する。

react-hook-formuseFieldArrayfield-arraydynamic-formformvalidationappendremovereact

どういう場面で使うか

  • ·フォームのフィールド数が実行時に変わる(タグ入力・複数宛先・項目リストなど)
  • ·追加 / 削除 / 並び替えが必要な繰り返しフィールドを管理したいとき
  • ·繰り返しフィールドごとにバリデーションを適用したいとき
  • ·固定フィールドの useState や map による手動管理がコントロールしにくくなってきたとき

注意点 / Pitfalls

  • ·index を React の key に使うと、削除・並び替え時に入力値やフォーカスが意図しない要素に残る。field.id を key に使う
  • ·defaultValues で配列フィールドを初期化しないと、最初の append 前に errors が undefined になる場合がある
  • ·入力中の field array 値を onChange のたびに store に反映すると、キーストロークごとに store が更新される。submit 後にだけ store に移す
  • ·useFieldArray の fields と register のインデックスがずれると値の参照が壊れる。fields.map の中で register(`items.${index}.value`) の形式で登録する
  • ·submit 時に useFieldArray が付与する内部プロパティ(id など)がそのままサーバーに送られることがある。送信前に不要フィールドを除外する

補足

useFieldArray は react-hook-form の非制御コンポーネントベース設計を維持したまま動的フィールドを扱う手段。useState + map で自前実装するより、バリデーション・dirty 追跡・reset が一括で管理できる。ただし drag-and-drop UI など複雑な並び替えは追加ライブラリと組み合わせる。

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 バリデーションパターン

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例