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

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

React Hook Form v7 でフォームを組むパターン。useFieldArray の field repeat / Zod resolver でバリデーションを型に揃える方法までを 1 ページに統合。

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

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

React Hook Form v7 の useFieldArray で行の追加・削除・並べ替え、Controller で外部 UI コンポーネント連携、watch + 計算による合計表示までを触れる demo で確認する実装メモ。

検証日: 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.idkey に使う(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 コンポーネントとの連携)

registeruncontrolled な 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) を繰り返すより速い。

つまずいたポイント

  • 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 操作を自動でしない、useEffectinputRefs[lastIndex]?.focus()

関連 Topic / 関連書籍

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

tech-book.net /books/9784839966645

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

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

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

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

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

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

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

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

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

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

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

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

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

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

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

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

【POD】React &amp; Gatsby開発入門

竹本 雄貴
詳細を 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-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年

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

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

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

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

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

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

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

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

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

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

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

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

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

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

【POD】React &amp; Gatsby開発入門

竹本 雄貴
詳細を tech-book.net で見る

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