tech-book-labs
● ガイド(フォーム) · 2 セクション統合

React Hook Form v7 フォーム実装ガイド

React Hook Form v7 でフォームを組むパターン。useFieldArray での行の追加・削除・並べ替え、保存済みの行の削除をサーバーに伝える論理削除(実測つき)、Zod resolver でバリデーションを型に揃える方法までを 1 ページに統合。

著者:TechBook.net編集部 · 最終検証 2026-05-10
● セクション · 2026-09-21 · react-hook-form 7.75.0

React Hook Form v7 で動的フォーム(useFieldArray + Controller)

React Hook Form v7 の useFieldArray で行の追加・削除・並べ替え、Controller 連携、watch での合計表示までを触れる demo で確認。保存済みの行の削除をサーバーに伝える論理削除(_deleted フラグ / 削除 id の別管理)の送信データと、主キーを id にしたときの落とし穴も実測。

検証日: 2026-09-21(初版 2026-05-10)

使用バージョン: react-hook-form@7.75.0 / @hookform/resolvers@^5 / zod@4

対象: 入門は通っている、明細行や動的に増減するフォームを作りたい人

入門記事は /articles/react-hook-form-v7-zod-resolver/(register / handleSubmit / Zod resolver)を参照。本稿は 「行を増減する」「外部 UI と繋ぐ」 という本番でほぼ必ず必要になる中級パターン。

触って試す

明細(useFieldArray)
合計(watch + 計算): ¥560,000

請求書の明細を想定した動的フォーム:行追加・削除・上下並べ替え + watch による合計計算 + Zod 検証。

1. useFieldArray の基本

useFieldArray は 配列フィールド の操作 API を提供する。append / remove / move / insert など。

import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const Schema = z.object({
  customer: z.string().min(1),
  items: z.array(z.object({
    name: z.string().min(1),
    qty: z.coerce.number().int().min(1),
    unitPrice: z.coerce.number().int().min(0),
  })).min(1),
});
type Form = z.infer<typeof Schema>;

function Invoice() {
  const { control, register, handleSubmit } = useForm<Form>({
    resolver: zodResolver(Schema),
    defaultValues: { customer: "", items: [{ name: "", qty: 1, unitPrice: 0 }] },
  });

  const { fields, append, remove, move } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register("customer")} />
      {fields.map((field, i) => (
        <div key={field.id}>
          <input {...register(`items.${i}.name`)} />
          <input type="number" {...register(`items.${i}.qty`)} />
          <input type="number" {...register(`items.${i}.unitPrice`)} />
          <button type="button" onClick={() => remove(i)}>削除</button>
          <button type="button" onClick={() => move(i, i - 1)} disabled={i === 0}>↑</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "", qty: 1, unitPrice: 0 })}>+</button>
      <button>送信</button>
    </form>
  );
}

ポイント:

  • field.id は React の key 専用:field.id を key に使う(i は再描画で変わると壊れる)
  • register("items.0.name") のような配列パス文字列が type-safe:generic で field 名候補を補完(template literal で index を入れる時も型推論される)
  • append / prepend / insert / remove / move / swap / update / replace で配列を操作
  • defaultValues に items の初期値を必ず指定(空配列も明示的に [])

useFieldArray メソッドの早見表

fields[] に対する各メソッドが、内部の field.id を保持するか / 新規発行するか で挙動が変わる。これを取り違えると React の key 衝突や不要な remount を起こす。

useFieldArray のメソッドが field.id をどう扱うか — focus 維持 / 値維持の挙動はここで決まる (クリックで拡大)

実用での意味:

  • 行を上下移動(move / swap)→ ユーザーが入力中の文字も focus も維持される ✓
  • 末尾に追加(append)→ 新規行は新 id でクリーン状態
  • replace で全置換すると 全行 remount(注意:大量行で重い)

2. watch で合計計算(派生表示)

入力値の変更に追従して合計を再計算する典型パターン。

const items = watch("items");
const total = items?.reduce(
  (sum, i) => sum + (Number(i.qty) || 0) * (Number(i.unitPrice) || 0),
  0,
) ?? 0;

// JSX
<span>合計: ¥{total.toLocaleString()}</span>

ポイント:

  • watch("items") は入力ごとに re-render:重い計算は useMemo で囲む
  • Number() 変換が必要:<input type="number"> の value は string、Zod の coerce は submit 時しか走らない
  • 大量行で重い場合は useFormContext + useWatch を子コンポーネントで使い、トップレベルの再描画を避ける

3. Controller(外部 UI コンポーネントとの連携)

register は uncontrolled な input に向くが、MUI / Chakra / shadcn / 自作 ComboBox 等の controlled な custom UI には Controller が必要。

import { Controller } from "react-hook-form";

<Controller
  control={control}
  name={`items.${i}.name`}
  render={({ field, fieldState }) => (
    <CustomComboBox
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>

field には value / onChange / onBlur / name / ref が入る。これらを custom component の API に橋渡し することで、register と同じ挙動になる。

fieldState.error?.message で同フィールドのエラーも一緒に取れる。

4. ネストしたフィールドでも検証エラーが正しく path に紐付く

Zod 側で:

items: z.array(z.object({
  qty: z.coerce.number().int().min(1, "1 以上"),
})).min(1, "1 件以上"),

errors の取り出し:

{errors.items?.[0]?.qty?.message}    // "1 以上"
{errors.items?.message}              // "1 件以上"(配列全体のエラー)
{errors.items?.root?.message}        // ルートエラー(refine 等で .root に入る)

ネスト経路に沿って type-safe に narrowing される。

5. 行間で値を継承する(update / replace)

「上の行の値をコピーして追加」のようなパターンは:

const last = fields[fields.length - 1];
const lastValues = getValues(`items.${fields.length - 1}`);
append({ ...lastValues, qty: 1 });   // qty だけリセット

getValues で現時点の値スナップショットを取る(watch とは違って re-render しない)。

6. 全削除 → 一括差し替え

replace(newArr) で配列全体を差し替える(全行 remount される点に注意):

const { replace } = useFieldArray({ control, name: "items" });
replace([{ name: "MacBook Pro", qty: 1, unitPrice: 280000 }]);

CSV インポートなどで配列を入れ替える時に使う。remove(0) を繰り返すより速い。

7. 行の削除をサーバーに伝える(論理削除)

remove(i) は、その行をフォームの値から消します。新規作成のフォームならそれで足りますが、保存済みの明細を編集する画面では、送信データから行が消えるだけなので、サーバーは「どの行が削除されたのか」を知る方法がありません。伝え方は 2 通りあります。下の demo で切り替えて、送信される payload を見比べられます。

方式やること向く場面
フラグ方式行は残して update(i, { ...row, _deleted: true })。表示だけ打ち消し線にし、送信時に deleteIds と upsert に分ける「元に戻す」を付けたい、削除した行も一覧に見せておきたい
別管理方式remove(i) の前に主キーを別の state(removedIds)に控える行を画面から完全に消したい。取り消しは要らない

demo で 3 行(dbId 101 / 102 / 103)のうち 1 行を削除して送信した結果(react-hook-form 7.75 / zod 4、Playwright で操作して確認):

操作fields の行数送信された payload
フラグ方式で 102 を削除3 行(画面の表示は 2 行)upsert: 101・103 / deleteIds: [102]
フラグ方式で、品名を空にした 101 を削除3 行検証で止まらず deleteIds: [101](削除行は検証を飛ばしているため)
別管理方式で 102 を削除し、新規行を足してすぐ削除2 行upsert: 101・103 / deleteIds: [102](新規行は含まれない)

remove(i) だけで済ませた場合は、3 行目から deleteIds が無くなった形になり、102 はどこにも現れません。サーバー側が「送られてこなかった行は削除」と解釈する作りでない限り、削除は保存されません。

フラグ方式で外せない点が 3 つあります。

// 1) 削除フラグの立った行は検証しない(消す行の「必須」で送信が止まる)
const Schema = z.object({
  rows: z.array(Row).superRefine((rows, ctx) => {
    rows.forEach((r, i) => {
      if (r._deleted) return;
      if (!r.name.trim()) ctx.addIssue({ code: "custom", path: [i, "name"], message: "必須" });
    });
  }),
});

// 2) 新規行(主キーなし)はサーバーに伝える必要が無いので、普通に remove する
const del = (i: number) => {
  const row = getValues(`rows.${i}`);
  if (row.dbId == null) remove(i);
  else update(i, { ...row, _deleted: true });
};

// 3) 送信時に分ける
const onValid = (d: Form) => ({
  upsert: d.rows.filter((r) => !r._deleted).map(({ _deleted, ...r }) => r),
  deleteIds: d.rows.filter((r) => r._deleted && r.dbId != null).map((r) => r.dbId),
});

もう 1 つ、主キーの名前に注意が要ります。useFieldArray は各行に React の key 用の値を id という名前で付けるので、自分の主キーも id という名前にしていると、fields[i].id は RHF が振った値に置き換わります。demo の下の行で確かめた結果:

見ている場所値
fields[0].id(既定)2db0fbc6-… のような UUID(RHF が振る。実行のたびに変わる)
getValues("probe.0.id")101(フォームの値としては残っている)
useFieldArray({ keyName: "_key" }) のときの fields[0].id101

値そのものは失われないので送信データは正しいままですが、fields.map((f) => f.id) で主キーを取ろうとすると UUID が返ります。主キーは dbId のような別名にするか、keyName を変えておくと取り違えません(この demo は dbId にしています)。

つまずいたポイント

  • field.id を必ず key に:fields.map((f, i) => <div key={i}> だと並べ替え時に壊れる(同じ DOM が違うデータで再利用される)
  • Controller で <input> を包むのは過剰:単純 input は register で十分。Controller は MUI / Chakra など controlled UI 専用と思っておく
  • useFieldArray は <FormProvider> で繋がる:多階層分割なら FormProvider + useFormContext / useWatch
  • defaultValues を [] にすると min(1) で submit 時に拒否:UX を考えると最低 1 行で初期化([blankItem])
  • append 後に focus を新しい行に飛ばす:useFieldArray は focus 操作を自動でしない、useEffect で inputRefs[lastIndex]?.focus()

関連 Topic / 関連書籍

この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:

tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

ExpoでiOS/Android両対応アプリをJavaScriptで構築する

詳細を tech-book.net で見る
tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks / Eve Porcello / 宮崎 空 · オライリー・ジャパン · 2021年

React Hooks ベースで Web アプリを手を動かしながら実装する

詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov / 牧野 聡 · オライリー・ジャパン · 2017年

React コンポーネント指向と Flux データフローを実例で体得する

詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也 / 吉田 健人 / 高林 佳稀 · 技術評論社 · 2022年

『TypeScript と React/Next.js でつくる実践 Web アプリケーション開発』(2022 年 7 月、技術評論社)は

詳細を tech-book.net で見る
tech-book.net /books/9784844379546

【POD】React & Gatsby開発入門

竹本 雄貴

Gatsby でサイトを作りながら React のマークアップを習得する

詳細を tech-book.net で見る
● セクション · 2026-05-10 · react-hook-form 7.75.0

React Hook Form v7 + Zod でフォームを書く

React Hook Form v7 を Zod resolver と組み合わせ、refinement / 複数フィールド検証 / mode 設定 / errors のフィールド紐付けまでを触れる demo で確認する実装メモ。

検証日: 2026-05-10(初版 2026-05-09)

使用バージョン: react-hook-form@7.75.0 / @hookform/resolvers@^5 / zod@4

対象: React + TypeScript で型安全なフォームを最小ボイラープレートで書きたい人

React Hook Form v7 を Zod resolver と組み合わせて型安全なフォーム を組むパターン。refinement(複数フィールド検証)、mode(検証タイミング)、errors のフィールド紐付けを動く demo で確認します。

触って試す

email / password / confirm / age すべて検証付き。submit を押すと検証済の値が表示される。

最小サンプル

useForm({ resolver: zodResolver(Schema) }) で zod schema を validator として注入し、register("field") で input を bind:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Schema = z.object({
  email: z.string().email(),
  age: z.coerce.number().int().min(13),
});
type FormValues = z.infer<typeof Schema>;

function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: zodResolver(Schema),
  });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="number" {...register("age")} />
      {errors.age && <p>{errors.age.message}</p>}
      <button type="submit">Submit</button>
    </form>
  );
}

なぜ react-hook-form

  • uncontrolled で再レンダ最小:input が変わっても親の re-render が起きない(値は ref で管理)
  • Zod / Yup / Joi と組み合わせ可能:@hookform/resolvers で resolver 化
  • register で 1 行:複雑なフォームでもコード量が増えにくい
  • watch / setValue / getValues で動的フォームにも対応 — 行追加削除のような 配列フィールド は /articles/react-hook-form-v7-field-array/ で useFieldArray を使う

入力 → 検証 → 描画のパイプライン

「register を呼ぶだけで何が起きてるのか」がブラックボックスになりがち。1 枚の図で見ると掴みやすい。

React Hook Form + Zod の検証フロー — ref で値を持ち、エラーは subscribed フィールドだけが reactive (クリックで拡大)

ここで効いている設計:

  • 値の保管は ref(state ではない)→ 入力ごとに親が re-render しない
  • errors も subscribe 単位:errors.email を読んでいる component だけが errors の email 変化に反応
  • resolver は副作用なし:Zod schema を渡せば form-engine 側は生 zod を知らない(yup でも joi でも同じ interface)

refinement(複数フィールド)

.refine(predicate, { message, path }) で「password と confirm の一致」のような 複数フィールドにまたがる検証 を書く:

const Schema = z
  .object({ password: z.string().min(8), confirm: z.string() })
  .refine((d) => d.password === d.confirm, {
    message: "確認用パスワードが一致しません",
    path: ["confirm"],   // ← エラーを confirm フィールドに紐付け
  });

path: ["confirm"] を指定すると errors.confirm.message で取り出せる。

バリデーションタイミング(mode)

useForm({ mode }) で いつ検証が走るか を選ぶ。onTouched(フィールドを触ってから)が UX 的に推奨:

useForm({
  mode: "onTouched",     // フィールドを触ってから検証(推奨)
  // mode: "onChange",   // 入力ごと(うるさい)
  // mode: "onBlur",     // フォーカス外し時
  // mode: "onSubmit",   // submit 時のみ(デフォルト、Zod がエラー詳細を出さないと UX 悪い)
});

onTouched がデファクト推奨。

つまずいたポイント

  • z.coerce.number() で型変換:<input type="number"> の値は string で来るので、Zod 側で coerce
  • register("name") の name 文字列が type-safe:TypeScript が useForm<FormValues> の generic から候補補完
  • errors.email.message が undefined の場合:refine の path が間違っている、または mode が onSubmit で touch していない
  • reset() で初期値に戻す:submit 後の reset() を忘れがち
  • Controller を使うべき場面:custom UI コンポーネント(MUI Select / shadcn Combobox)は register だと値が取れない、Controller で render prop パターン

評価

観点評価コメント
学習コスト○register / handleSubmit / formState の 3 つを覚えれば開始できる
型サポート◎generic で field 名が補完される
再レンダ最適化◎uncontrolled で input 単位の再描画
Zod 統合◎@hookform/resolvers/zod で 1 行
エコシステム◎MUI / Chakra / shadcn と統合例多数

向く / 向かないケース

  • 向く: 静的フォーム / 動的 field 追加削除 / 多段ウィザード / 複数フィールド refinement
  • 向かない: 1〜2 field の超単純なケース(useState で十分)
  • 向かない: フォームライブラリを排除した極限の bundle 削減

関連 Topic / 関連書籍

この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:

tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

ExpoでiOS/Android両対応アプリをJavaScriptで構築する

詳細を tech-book.net で見る
tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks / Eve Porcello / 宮崎 空 · オライリー・ジャパン · 2021年

React Hooks ベースで Web アプリを手を動かしながら実装する

詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov / 牧野 聡 · オライリー・ジャパン · 2017年

React コンポーネント指向と Flux データフローを実例で体得する

詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也 / 吉田 健人 / 高林 佳稀 · 技術評論社 · 2022年

『TypeScript と React/Next.js でつくる実践 Web アプリケーション開発』(2022 年 7 月、技術評論社)は

詳細を tech-book.net で見る
tech-book.net /books/9784844379546

【POD】React & Gatsby開発入門

竹本 雄貴

Gatsby でサイトを作りながら React のマークアップを習得する

詳細を tech-book.net で見る

全ガイドは ガイド一覧 から。連載は 連載一覧 へ。