React Hook Form v7 フォーム実装ガイド
React Hook Form v7 でフォームを組むパターン。useFieldArray の field repeat / Zod resolver でバリデーションを型に揃える方法までを 1 ページに統合。
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 と繋ぐ」 という本番でほぼ必ず必要になる中級パターン。
触って試す
請求書の明細を想定した動的フォーム:行追加・削除・上下並べ替え + 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 を起こす。
実用での意味:
- 行を上下移動(
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) を繰り返すより速い。
つまずいたポイント
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 に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…
【POD】React & Gatsby開発入門
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 枚の図で見ると掴みやすい。
ここで効いている設計:
- 値の保管は 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 側で coerceregister("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 に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…