「React Hook Form 使い方を実務レベルで身につけたい」「registerとControllerの使い分けがいまいち分からない」「Zod / Yup / valibot連携、useFieldArrayでの動的フィールド、ファイルアップロード、多段階フォーム、MUI / Chakra UIとの統合、テスト戦略までを一気通貫で押さえたい」――この記事はそんな現役WebエンジニアのためのReact Hook Form完全実践ガイドです。React Hook Form(以下RHF)は2019年の登場以来、「非制御コンポーネントベース・再レンダリング最小・TypeScriptフレンドリー」という設計思想で、Formikを置き換える形でデファクトスタンダードに上り詰めました。本記事では、useFormの基本から、register / Controller / handleSubmit / defaultValues / mode / setValue / getValues / watch / Zod & valibot連携 / useFieldArray / 入れ子フォーム / ファイルアップロード / 非同期バリデーション / 多段階ウィザード / shadcn-ui統合 / Next.js Server Actions連携 / テスト戦略 / パフォーマンス最適化 / アクセシビリティまで、35本超のTypeScriptコードブロック・表3つ・FAQ6問で徹底解説します。読み終えるころには、新規プロジェクトのフォーム設計を「useContextでやるか、自前useReducerでやるか、それともRHFか」と迷わずに済むようになっているはずです。
- React Hook Formの全体像〜なぜ非制御コンポーネントが速いのか
- インストールと最小構成のフォーム
- register / Controller の使い分け〜RHFで最も誤解されるポイント
- watch / setValue / getValues / reset〜RHFの動的操作API
- Zodと@hookform/resolversで型安全なバリデーション
- useFieldArrayで動的フィールドを扱う
- ラジオ・チェックボックス・セレクト・ファイル
- 非同期バリデーション・サーバーエラー連携
- 多段階フォーム(ウィザード)
- shadcn/ui・MUI・Chakra との統合パターン
- Next.js Server Actions / SSR連携
- テスト戦略〜React Testing Library + userEvent
- パフォーマンス最適化のベストプラクティス
- アクセシビリティ(a11y)対応
- よくある落とし穴とトラブルシューティング
- FAQ
- まとめ〜React Hook Formは「型・速度・統合性」で選ぶ
React Hook Formの全体像〜なぜ非制御コンポーネントが速いのか
React Hook Formは、入力欄を非制御コンポーネント(uncontrolled component)として扱うことで、キーストロークごとの親の再レンダリングを発生させないという設計を取っています。Formikやreact-final-formが「親stateで全フィールドを管理する制御コンポーネント方式」を採るのに対し、RHFは内部のrefを使ってDOMから直接値を読むため、フィールドが100個あっても親が再描画されません。これが、大規模フォームでRHFが圧倒的に速い理由です。
Formik / react-final-form / TanStack Form / Conformとの比較
2026年時点の主要フォームライブラリを、機能差で並べると以下のように整理できます。Formikはメンテナンスペースが落ちており、新規採用は推奨しづらい状況です。TanStack FormはSolid / Vue / Lit対応の野心的なライブラリですが、RHFほどエコシステムが厚くありません。Conformはサーバーアクション連携が強く、Next.js / Remix前提なら有力選択肢です。
| ライブラリ | 方式 | 再レンダリング | バンドルサイズ | 2026年の推奨度 |
|---|---|---|---|---|
| React Hook Form | 非制御中心 | 最小(フィールド単位) | 約9KB(gzip) | ★★★★★ 第一候補 |
| Formik | 制御中心 | 多い(親再描画) | 約13KB | ★★ 既存維持のみ |
| react-final-form | Subscription | 中 | 約8KB | ★★ 新規非推奨 |
| TanStack Form | 非制御 + signals | 最小 | 約12KB | ★★★ 新興・要観察 |
| Conform | サーバーアクション前提 | HTML標準 | 約6KB | ★★★★ Next/Remix限定 |
RHFが向くケース・向かないケース
RHFが特に強みを発揮するのは、フィールド数が10個を超える業務フォーム、動的にフィールドを追加削除する見積もり/請求書系、Zodスキーマでバックエンドと型を共有したいフルスタックTS環境です。逆に、フィールドが2〜3個しかないシンプルなログインフォームでは、素のuseStateでも十分速く、RHFを入れる価値は薄くなります。下記のリストを判断材料にしてください。
- RHFを使うべきケース: 5フィールド以上、動的フィールド有り、Zodで型共有、再レンダリング負荷が気になる、デザインシステムと組み合わせる
- RHFを使わなくて良いケース: 2〜3フィールドのみ、サーバーサイドのみで完結、Next.js Server Actionsで十分
- 他を検討すべきケース: Next.js App Routerで“中心ならConform、Tanstack Routerと統一感が欲しいならTanStack Form
インストールと最小構成のフォーム
npm / pnpm / yarn でのインストール
本体だけならワンライナーで終わります。Reactは18以上、TypeScriptは5系を前提とします。スキーマバリデーション(Zod / Yup / valibot)を併用する場合は、対応するresolverも併せて入れます。
# 本体
npm install react-hook-form
pnpm add react-hook-form
yarn add react-hook-form
# Zod連携(後述の主流構成)
npm install zod @hookform/resolvers
# Yup連携
npm install yup @hookform/resolvers
# valibot連携(軽量・Tree shakable)
npm install valibot @hookform/resolvers
最小構成: 5行で動くフォーム
まずは「メールとパスワードを送信するだけ」の最小例を見ます。useFormから取り出したregisterを入力欄に展開すれば、値の保持・refの設定・onChangeの紐付けがすべて自動で行われます。FormikやuseStateベース実装と比べて、定型コードがほぼゼロです。
// 最小フォーム: これだけで動く
import { useForm } from "react-hook-form";
type LoginForm = {
email: string;
password: string;
};
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();
const onSubmit = (data: LoginForm) => {
console.log(data); // { email: "...", password: "..." }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "メール必須" })} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password", { required: true, minLength: 8 })} />
<button type="submit">ログイン</button>
</form>
);
}
defaultValuesとTypeScript型推論
実務では必ずdefaultValuesを指定するのが鉄則です。指定しないとフィールドの初期値がundefinedになり、reset()やwatch()の挙動が読みづらくなります。型はuseForm<T>のジェネリクスで渡すか、Zodスキーマから推論します(後述)。
// defaultValues を明示するパターン
import { useForm, type SubmitHandler } from "react-hook-form";
type ProfileForm = {
name: string;
age: number;
newsletter: boolean;
};
export function ProfileFormApp() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<ProfileForm>({
defaultValues: { name: "", age: 20, newsletter: true },
mode: "onBlur", // blurごとに検証
reValidateMode: "onChange", // エラー発生後はonChangeで再検証
});
const onSubmit: SubmitHandler<ProfileForm> = async (data) => {
await fetch("/api/profile", { method: "POST", body: JSON.stringify(data) });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "名前必須" })} />
{errors.name && <span>{errors.name.message}</span>}
<input type="number" {...register("age", { valueAsNumber: true, min: 0 })} />
<label>
<input type="checkbox" {...register("newsletter")} /> ニュースレター
</label>
<button type="submit" disabled={isSubmitting}>保存</button>
</form>
);
}
register / Controller の使い分け〜RHFで最も誤解されるポイント
registerは「素のinputに使う」
registerは、HTML標準のinput / select / textareaに対してref経由で値を直接取得する関数です。スプレッドで{...register("name")}と展開するだけで、ライブラリ側が必要なpropsをすべて差し込みます。再レンダリングが起きないため、超高速です。
// register: 標準HTML要素ならこれ一択
import { useForm } from "react-hook-form";
type SignupForm = { username: string; bio: string; country: "JP" | "US" | "UK" };
export function SignupForm() {
const { register, handleSubmit } = useForm<SignupForm>({
defaultValues: { username: "", bio: "", country: "JP" },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("username")} />
<textarea {...register("bio")} />
<select {...register("country")}>
<option value="JP">日本</option>
<option value="US">USA</option>
<option value="UK">UK</option>
</select>
<button type="submit">送信</button>
</form>
);
}
Controllerは「制御コンポーネントに使う」
MUIの<TextField>、Chakraの<Input>、react-selectのようなライブラリ製コンポーネントは、内部で値を制御しているためrefでは値が取れません。こういう場合はControllerを使い、onChange / value / refをRHFの内部状態とブリッジします。
// Controller: MUI / Chakra / react-select 等の制御コンポーネント用
import { useForm, Controller } from "react-hook-form";
import { TextField, Autocomplete } from "@mui/material";
type Form = { title: string; tags: string[] };
export function MuiForm() {
const { control, handleSubmit } = useForm<Form>({
defaultValues: { title: "", tags: [] },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="title"
control={control}
rules={{ required: "タイトル必須", maxLength: 50 }}
render={({ field, fieldState: { error } }) => (
<TextField {...field} label="タイトル" error={!!error} helperText={error?.message} />
)}
/>
<Controller
name="tags"
control={control}
render={({ field: { onChange, value } }) => (
<Autocomplete
multiple freeSolo options={["React", "TypeScript", "Form"]}
value={value} onChange={(_, v) => onChange(v)}
renderInput={(p) => <TextField {...p} label="タグ" />}
/>
)}
/>
<button type="submit">送信</button>
</form>
);
}
使い分けのフローチャート
判断基準は「そのコンポーネントが内部でvalueを制御しているか」の一点です。標準input/select/textareaは非制御として扱えるのでregister、ライブラリ製コンポーネントはほぼ全て制御コンポーネントなのでControllerと覚えてください。
| コンポーネント | register | Controller | 備考 |
|---|---|---|---|
| <input type=”text”> | ○ | △ | registerで十分 |
| <input type=”checkbox”> | ○ | △ | registerで十分 |
| <input type=”file”> | ○ | × | FileListとして取得 |
| MUI TextField | × | ○ | Controllerが必須 |
| Chakra Input | ○ | ○ | v2系は両対応・v3系は要Controller |
| react-select | × | ○ | Controllerが必須 |
| react-datepicker | × | ○ | Controllerが必須 |
| shadcn/ui (Radix) | ○ | ○ | 状況による |
watch / setValue / getValues / reset〜RHFの動的操作API
watchで値を購読する
watchは指定フィールドの値変更時に再レンダリングを発生させて値を返す関数です。便利ですがコンポーネント全体が再描画されるため、大規模フォームではuseWatch(後述)に切り替えるか、watchの対象を絞ってください。
// watch: 値の変化を読み取る(全体再描画)
import { useForm } from "react-hook-form";
type Order = { quantity: number; price: number; total: number };
export function OrderForm() {
const { register, watch } = useForm<Order>({
defaultValues: { quantity: 1, price: 1000, total: 1000 },
});
const quantity = watch("quantity");
const price = watch("price");
const total = quantity * price;
return (
<form>
<input type="number" {...register("quantity", { valueAsNumber: true })} />
<input type="number" {...register("price", { valueAsNumber: true })} />
<p>合計: {total.toLocaleString()}円</p>
</form>
);
}
useWatchで部分購読(パフォーマンス改善)
useWatchは呼び出し元のコンポーネントだけを再レンダリングします。「合計表示部分」を別コンポーネントに切り出してuseWatchを使うのが、大規模フォームでの常套手段です。
// useWatch: 必要なコンポーネントだけ再描画
import { useForm, useWatch, type Control } from "react-hook-form";
type Order = { quantity: number; price: number };
function TotalDisplay({ control }: { control: Control<Order> }) {
// この子だけが再描画される
const quantity = useWatch({ control, name: "quantity" });
const price = useWatch({ control, name: "price" });
return <p>合計: {(quantity * price).toLocaleString()}円</p>;
}
export function FastOrderForm() {
const { register, control } = useForm<Order>({
defaultValues: { quantity: 1, price: 1000 },
});
return (
<form>
<input type="number" {...register("quantity", { valueAsNumber: true })} />
<input type="number" {...register("price", { valueAsNumber: true })} />
<TotalDisplay control={control} />
</form>
);
}
setValue / getValues / reset
外部イベント(APIレスポンス・別フィールドの選択)を起点に値を更新するときはsetValue、ボタン押下時に現在値を読みたいだけなら再描画不要なgetValues、フォーム全体を初期化したいときはresetを使います。setValueの第3引数でバリデーション再実行とdirty判定の挙動を制御できます。
// setValue / getValues / reset の典型パターン
import { useForm } from "react-hook-form";
type Address = { zip: string; pref: string; city: string };
export function AddressForm() {
const { register, setValue, getValues, reset, handleSubmit } =
useForm<Address>({ defaultValues: { zip: "", pref: "", city: "" } });
const fillFromZip = async () => {
const zip = getValues("zip"); // 再描画しないで現在値を取得
const res = await fetch(`https://zipcloud.ibsnet.co.jp/api/search?zipcode=${zip}`);
const json = await res.json();
if (json.results) {
// 検証も再実行 + dirty扱いにする
setValue("pref", json.results[0].address1, { shouldValidate: true, shouldDirty: true });
setValue("city", json.results[0].address2 + json.results[0].address3, { shouldValidate: true });
}
};
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("zip")} />
<button type="button" onClick={fillFromZip}>郵便番号から住所取得</button>
<input {...register("pref")} />
<input {...register("city")} />
<button type="button" onClick={() => reset()}>クリア</button>
<button type="submit">送信</button>
</form>
);
}
Zodと@hookform/resolversで型安全なバリデーション
なぜZodが標準になったのか
2026年時点でRHF + Zodは事実上の業界標準です。理由は3つあります。1つ目はスキーマからz.infer<typeof Schema>でTypeScript型を自動生成できること。2つ目はrefine / superRefineでカスタムバリデーションが宣言的に書けること。3つ目は同じスキーマをサーバー側でも使い回せること。これはZod登場前のYup時代には実現できなかった体験です。
Zodスキーマの定義
// Zodスキーマ: 型 + バリデーション規則を一箇所に集約
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("メール形式不正"),
password: z.string().min(8, "8文字以上").regex(/[A-Z]/, "大文字を含めて"),
passwordConfirm: z.string(),
age: z.number().int().min(18, "18歳以上"),
agree: z.literal(true, { errorMap: () => ({ message: "同意必須" }) }),
}).refine((d) => d.password === d.passwordConfirm, {
message: "パスワード不一致",
path: ["passwordConfirm"],
});
// 型を自動生成 → これをuseFormにそのまま渡す
export type SignupInput = z.infer<typeof signupSchema>;
zodResolverでフォームに繋ぐ
// zodResolver: スキーマをuseFormに連携
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput } from "./schemas";
export function SignupFormZod() {
const { register, handleSubmit, formState: { errors, isValid } } =
useForm<SignupInput>({
resolver: zodResolver(signupSchema),
mode: "onBlur",
defaultValues: {
email: "", password: "", passwordConfirm: "", age: 18, agree: false as never,
},
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
<input type="password" {...register("passwordConfirm")} />
{errors.passwordConfirm && <span>{errors.passwordConfirm.message}</span>}
<input type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<label>
<input type="checkbox" {...register("agree")} />規約に同意
</label>
{errors.agree && <span>{errors.agree.message}</span>}
<button type="submit" disabled={!isValid}>登録</button>
</form>
);
}
Yup連携(参考: 既存プロジェクトの移行先指南)
Yupは長らくRHFと組み合わされてきた老舗ライブラリで、既存プロジェクトでは未だに多用されています。新規プロジェクトでZodを選ぶのは「TypeScript統合の品質」が主因ですが、Yupでも以下のように同等のことは可能です。
// Yup連携(既存プロジェクト互換)
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
}).required();
type Form = yup.InferType<typeof schema>;
export function YupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Form>({
resolver: yupResolver(schema),
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("email")} />{errors.email?.message}
<input {...register("password")} />{errors.password?.message}
<button type="submit">送信</button>
</form>
);
}
valibot連携(参考: バンドル軽量化したいとき)
valibotはZodの設計思想を踏襲しつつ、Tree shakingで未使用APIをバンドルから除去できるのが強みです。バンドルサイズが厳しいモバイル向けやEdge環境では選択肢に上がります。
// valibot連携: Tree shakable
import { useForm } from "react-hook-form";
import { valibotResolver } from "@hookform/resolvers/valibot";
import * as v from "valibot";
const schema = v.object({
email: v.pipe(v.string(), v.email("メール形式不正")),
age: v.pipe(v.number(), v.minValue(18, "18歳以上")),
});
type Form = v.InferOutput<typeof schema>;
export function ValibotForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Form>({
resolver: valibotResolver(schema),
defaultValues: { email: "", age: 18 },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("email")} />{errors.email?.message}
<input type="number" {...register("age", { valueAsNumber: true })} />{errors.age?.message}
<button type="submit">送信</button>
</form>
);
}
useFieldArrayで動的フィールドを扱う
append / remove / move / insertの基本
請求書の明細行・連絡先リスト・タグ入力などで必須となるのが動的フィールドです。RHFではuseFieldArrayがこれを引き受けます。配列の操作は必ずappend / remove / move / insert / swapのAPI経由で行い、JSの.push()等は使わないでください。内部のid管理が崩れます。
// useFieldArray: 動的な明細行
import { useForm, useFieldArray } from "react-hook-form";
type Invoice = {
client: string;
items: { name: string; price: number; qty: number }[];
};
export function InvoiceForm() {
const { register, control, handleSubmit, watch } = useForm<Invoice>({
defaultValues: { client: "", items: [{ name: "", price: 0, qty: 1 }] },
});
const { fields, append, remove, move } = useFieldArray({ control, name: "items" });
const items = watch("items");
const total = items.reduce((sum, i) => sum + (i.price ?? 0) * (i.qty ?? 0), 0);
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("client", { required: true })} placeholder="クライアント名" />
{fields.map((field, idx) => (
<div key={field.id}>
<input {...register(`items.${idx}.name`)} placeholder="品名" />
<input type="number" {...register(`items.${idx}.price`, { valueAsNumber: true })} />
<input type="number" {...register(`items.${idx}.qty`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(idx)}>削除</button>
{idx > 0 && <button type="button" onClick={() => move(idx, idx - 1)}>↑</button>}
</div>
))}
<button type="button" onClick={() => append({ name: "", price: 0, qty: 1 })}>
行を追加
</button>
<p>合計: {total.toLocaleString()}円</p>
<button type="submit">送信</button>
</form>
);
}
動的フィールドの注意点〜keyは必ずfield.idを使う
map内のkeyには必ずfield.id(RHFが内部発行するUUID)を使ってください。配列インデックスをkeyに使うと、削除時にReactが要素を取り違えてフィールド値がずれるバグが頻発します。これはRHFに限らず、Reactの動的リストすべての鉄則です。
Zod + useFieldArrayの型安全
// Zodで配列の最小要素数も保証する
import { z } from "zod";
export const invoiceSchema = z.object({
client: z.string().min(1, "必須"),
items: z.array(z.object({
name: z.string().min(1, "品名必須"),
price: z.number().nonnegative(),
qty: z.number().int().min(1, "1以上"),
})).min(1, "明細を1行以上追加してください"),
});
export type InvoiceForm = z.infer<typeof invoiceSchema>;
ラジオ・チェックボックス・セレクト・ファイル
チェックボックスのbool / 配列パターン
チェックボックスは「1個でon/off判定したい」のか「複数チェックで配列にしたい」のかで実装が変わります。前者はregisterそのまま、後者はvalueを渡してRHFに配列を組み立てさせます。
// チェックボックス: 単一bool / 複数配列
type Form = {
agree: boolean; // 単一bool
hobbies: string[]; // 複数選択
};
export function CheckboxForm() {
const { register, handleSubmit } = useForm<Form>({
defaultValues: { agree: false, hobbies: [] },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<label><input type="checkbox" {...register("agree")} />規約同意</label>
<label><input type="checkbox" value="読書" {...register("hobbies")} />読書</label>
<label><input type="checkbox" value="映画" {...register("hobbies")} />映画</label>
<label><input type="checkbox" value="旅行" {...register("hobbies")} />旅行</label>
<button type="submit">送信</button>
</form>
);
}
ラジオボタン
// ラジオボタン
type Form = { plan: "free" | "pro" | "enterprise" };
export function RadioForm() {
const { register, handleSubmit } = useForm<Form>({ defaultValues: { plan: "free" } });
return (
<form onSubmit={handleSubmit(console.log)}>
<label><input type="radio" value="free" {...register("plan")} />無料</label>
<label><input type="radio" value="pro" {...register("plan")} />プロ</label>
<label><input type="radio" value="enterprise" {...register("plan")} />エンタープライズ</label>
<button type="submit">送信</button>
</form>
);
}
ファイルアップロード(単一・複数・プレビュー)
ファイル系はregisterで受けるとFileListとして取れます。プレビューやサイズ検証はZodのrefineと組み合わせるのが綺麗です。
// ファイルアップロード: 型 + サイズ検証
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const schema = z.object({
avatar: z.instanceof(FileList)
.refine((f) => f.length === 1, "1ファイル選択")
.refine((f) => f[0]?.size <= MAX_SIZE, "5MB以下")
.refine((f) => ["image/png", "image/jpeg"].includes(f[0]?.type ?? ""), "PNG/JPGのみ"),
});
type Form = z.infer<typeof schema>;
export function FileUploadForm() {
const { register, handleSubmit, watch, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema),
});
const files = watch("avatar");
const previewUrl = files?.[0] ? URL.createObjectURL(files[0]) : null;
return (
<form onSubmit={handleSubmit(console.log)}>
<input type="file" accept="image/*" {...register("avatar")} />
{errors.avatar && <span>{errors.avatar.message as string}</span>}
{previewUrl && <img src={previewUrl} alt="preview" width={120} />}
<button type="submit">アップロード</button>
</form>
);
}
非同期バリデーション・サーバーエラー連携
サーバー側のユニーク検証
「ユーザー名重複チェック」のようにサーバー問合せが必要なバリデーションは、Zodのrefineをasync関数で書くか、registerのvalidateに非同期関数を渡します。後者の方が単一フィールド向きで挙動が読みやすいです。
// 非同期バリデーション: register.validate に async関数
import { useForm } from "react-hook-form";
type Form = { username: string };
export function AsyncValidateForm() {
const { register, handleSubmit, formState: { errors, isValidating } } =
useForm<Form>({ mode: "onBlur" });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("username", {
required: "必須",
validate: async (value) => {
const res = await fetch(`/api/usernames/check?value=${encodeURIComponent(value)}`);
const json = await res.json();
return json.available || "このユーザー名は使えません";
},
})} />
{isValidating && <span>確認中...</span>}
{errors.username && <span>{errors.username.message}</span>}
<button type="submit">送信</button>
</form>
);
}
setError でサーバーエラーをフィールドに反映
サーバーからの422レスポンス等を、特定フィールドのエラー表示に流し込むにはsetErrorを使います。型安全に書くため、サーバー側もZodスキーマで422を返すとフロント側のマッピングが綺麗にハマります。
// setError: API 422レスポンスをフィールドエラーへ
import { useForm } from "react-hook-form";
type Form = { email: string; password: string };
type ApiError = { field: keyof Form; message: string }[];
export function ServerErrorForm() {
const { register, handleSubmit, setError, formState: { errors } } =
useForm<Form>();
const onSubmit = async (data: Form) => {
const res = await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
if (res.status === 422) {
const errs: ApiError = await res.json();
errs.forEach((e) => setError(e.field, { type: "server", message: e.message }));
return;
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />{errors.email?.message}
<input type="password" {...register("password")} />{errors.password?.message}
<button type="submit">ログイン</button>
</form>
);
}
多段階フォーム(ウィザード)
FormProviderで子コンポーネントに分割
住所入力 → クレカ入力 → 確認 のような3ステップウィザードは、FormProviderでメソッドをContext越しに配って、各ステップを別コンポーネントに分割するとシンプルに書けます。useContextの基本を理解していると、FormProviderの仕組みもすぐ腹落ちするはずです。
// FormProvider: メソッドをContextで配る
import { useForm, FormProvider, useFormContext } from "react-hook-form";
import { useState } from "react";
type CheckoutForm = {
name: string; email: string;
zip: string; address: string;
card: string; expiry: string;
};
function Step1() {
const { register } = useFormContext<CheckoutForm>();
return (
<>
<input {...register("name", { required: true })} placeholder="名前" />
<input {...register("email", { required: true })} placeholder="メール" />
</>
);
}
function Step2() {
const { register } = useFormContext<CheckoutForm>();
return (
<>
<input {...register("zip", { required: true })} placeholder="郵便番号" />
<input {...register("address", { required: true })} placeholder="住所" />
</>
);
}
function Step3() {
const { register } = useFormContext<CheckoutForm>();
return (
<>
<input {...register("card", { required: true })} placeholder="カード番号" />
<input {...register("expiry", { required: true })} placeholder="MM/YY" />
</>
);
}
export function Wizard() {
const [step, setStep] = useState(0);
const methods = useForm<CheckoutForm>({ mode: "onBlur" });
const next = async () => {
const ok = await methods.trigger(); // 現ステップの検証
if (ok) setStep((s) => s + 1);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit((d) => console.log(d))}>
{step === 0 && <Step1 />}
{step === 1 && <Step2 />}
{step === 2 && <Step3 />}
<div>
{step > 0 && <button type="button" onClick={() => setStep(step - 1)}>戻る</button>}
{step < 2 && <button type="button" onClick={next}>次へ</button>}
{step === 2 && <button type="submit">確定</button>}
</div>
</form>
</FormProvider>
);
}
ステップごとの部分検証(trigger)
ウィザードでは次へボタン押下時に「現在表示中フィールドだけ」を検証したいことが多いです。trigger(["name", "email"])のようにフィールド名を配列で渡せば、対象を絞った検証ができます。
// trigger: 部分検証
const okStep1 = await methods.trigger(["name", "email"]);
const okStep2 = await methods.trigger(["zip", "address"]);
if (!okStep1) return; // 該当フィールドだけエラー表示
shadcn/ui・MUI・Chakra との統合パターン
shadcn/ui Form コンポーネント(2026年の事実上標準)
shadcn/uiのForm系コンポーネントは内部でRHFを直接利用しており、ラベル・エラー表示・aria属性を自動で整えてくれます。Next.js + Tailwind + shadcn/ui + Zod + RHFは、2026年時点で最も生産性の高いフォーム実装スタックです。
// shadcn/ui Form + Zod + RHF
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form, FormField, FormItem, FormLabel, FormControl, FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export function ShadcnLoginForm() {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>メールアドレス</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>パスワード</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<Button type="submit">ログイン</Button>
</form>
</Form>
);
}
MUI v6との統合(Controllerで包む)
// MUI v6 + RHF: Controller でラップ
import { TextField, Checkbox, FormControlLabel } from "@mui/material";
import { Controller, useForm } from "react-hook-form";
type Form = { name: string; subscribe: boolean };
export function MuiV6Form() {
const { control, handleSubmit } = useForm<Form>({
defaultValues: { name: "", subscribe: false },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller name="name" control={control} rules={{ required: true }}
render={({ field, fieldState }) => (
<TextField {...field} label="名前" error={!!fieldState.error}
helperText={fieldState.error?.message} />
)} />
<Controller name="subscribe" control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox checked={field.value} onChange={field.onChange} />}
label="購読する"
/>
)} />
</form>
);
}
Next.js Server Actions / SSR連携
Server Actions と RHFのハイブリッド
Next.js App Router環境では、サーバー側バリデーションをServer Actions、クライアント側UXをRHFと分担するのが綺麗な構成です。Zodスキーマを両側で共有することで、二重実装を避けられます。
// app/actions/signup.ts: サーバーアクション
"use server";
import { signupSchema } from "@/schemas/signup";
export async function signupAction(_: unknown, formData: FormData) {
const parsed = signupSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
passwordConfirm: formData.get("passwordConfirm"),
age: Number(formData.get("age")),
agree: formData.get("agree") === "on",
});
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
// DBに保存等...
return { ok: true };
}
// app/signup/SignupForm.tsx: クライアント側
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signupSchema, type SignupInput } from "@/schemas/signup";
import { signupAction } from "../actions/signup";
export function SignupClientForm() {
const { register, handleSubmit, setError, formState: { errors } } =
useForm<SignupInput>({ resolver: zodResolver(signupSchema) });
const onSubmit = async (data: SignupInput) => {
const fd = new FormData();
Object.entries(data).forEach(([k, v]) => fd.set(k, String(v)));
const res = await signupAction({}, fd);
if (!res.ok && res.errors) {
Object.entries(res.errors).forEach(([k, msgs]) => {
if (msgs) setError(k as keyof SignupInput, { message: msgs[0] });
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />{errors.email?.message}
<input type="password" {...register("password")} />{errors.password?.message}
<button type="submit">登録</button>
</form>
);
}
SSRで初期値を渡す
// 編集画面: サーバーで初期値を取得 → defaultValuesに渡す
"use client";
import { useForm } from "react-hook-form";
type Props = { initial: { name: string; email: string } };
export function EditProfileClient({ initial }: Props) {
const { register, handleSubmit, reset } = useForm({ defaultValues: initial });
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("name")} />
<input {...register("email")} />
<button type="button" onClick={() => reset(initial)}>リセット</button>
<button type="submit">保存</button>
</form>
);
}
テスト戦略〜React Testing Library + userEvent
テスト方針: 「ライブラリの内部」ではなく「ユーザー操作」を試す
RHFのテストで頻発する失敗は、registerのpropsを直接呼んで動作確認しようとするケースです。これはライブラリの実装詳細に依存するため、バージョンアップで壊れます。ユーザー視点(クリック・入力・送信)でテストするのが正解です。React Hooksの基礎を踏まえた上で、Hookごとのテストではなく「フォーム全体のシナリオテスト」を書く設計にしてください。
// Vitest + RTL + userEvent
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { LoginForm } from "./LoginForm";
describe("LoginForm", () => {
it("メール未入力ならエラー表示", async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole("button", { name: /ログイン/i }));
expect(await screen.findByText(/メール必須/)).toBeInTheDocument();
});
it("正常入力でsubmit成功", async () => {
const user = userEvent.setup();
const submit = vi.fn();
render(<LoginForm onSubmit={submit} />);
await user.type(screen.getByLabelText(/メール/), "test@example.com");
await user.type(screen.getByLabelText(/パスワード/), "Password123");
await user.click(screen.getByRole("button", { name: /ログイン/i }));
expect(submit).toHaveBeenCalledWith(
expect.objectContaining({ email: "test@example.com" }),
expect.anything(),
);
});
});
useFieldArrayのテスト(動的行追加)
// 動的行追加のテスト
it("行追加 → 削除が正しく動く", async () => {
const user = userEvent.setup();
render(<InvoiceForm />);
await user.click(screen.getByRole("button", { name: /行を追加/ }));
expect(screen.getAllByPlaceholderText("品名")).toHaveLength(2);
await user.click(screen.getAllByRole("button", { name: /削除/ })[1]);
expect(screen.getAllByPlaceholderText("品名")).toHaveLength(1);
});
パフォーマンス最適化のベストプラクティス
大規模フォームで効くテクニック
- useWatchで部分購読: 表示部だけ切り出して
watchを避ける - shouldUnregister: false: タブ切替時に値を保持(デフォルトfalse)
- FormProviderの粒度: 巨大フォームは複数のFormProviderに分割せず、
useFormContextで参照だけ受け取る - defaultValuesは安定参照: コンポーネント外で定義するかuseMemoで安定化
- Controllerは必要時のみ: registerで済むものまでControllerにすると遅くなる
defaultValues の安定参照
// 悪い例: 毎レンダリングで新オブジェクト → resetの挙動が壊れる
const { register } = useForm({ defaultValues: { name: "" } });
// 良い例: コンポーネント外で定義 or useMemoで固定
const DEFAULT_VALUES = { name: "" };
const { register } = useForm({ defaultValues: DEFAULT_VALUES });
// SSRから値を受ける場合
const defaultValues = useMemo(() => initial, [initial]);
const { register } = useForm({ defaultValues });
controlled vs uncontrolled の使い分け表
| ケース | register(uncontrolled) | Controller(controlled) |
|---|---|---|
| 標準input/select/textarea | ○ 第一選択 | 不要 |
| MUI / Chakra / Mantine | × | ○ 必須 |
| react-select / react-datepicker | × | ○ 必須 |
| 値を即座に他に反映したい(プレビュー等) | watch + 再描画 | onChangeで副作用 |
| フィールド数100以上 | ○ 圧倒的に速い | 負荷大 |
アクセシビリティ(a11y)対応
ラベル・aria属性・エラー読み上げ
フォームのa11y品質は「ラベルがinputと紐付いているか」「エラー発生時にスクリーンリーダーが読み上げるか」で評価されます。aria-invalidとaria-describedbyを組み合わせるのが標準パターンです。
// アクセシブルなフォーム
import { useForm } from "react-hook-form";
type Form = { email: string };
export function A11yForm() {
const { register, handleSubmit, formState: { errors } } = useForm<Form>();
return (
<form onSubmit={handleSubmit(console.log)}>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email", { required: "メール必須", pattern: /^.+@.+$/ })}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email.message}</span>
)}
<button type="submit">送信</button>
</form>
);
}
shadcn/uiが標準で面倒を見てくれる
shadcn/uiのFormField / FormLabel / FormMessageはaria-describedby / aria-invalidを自動で付与します。a11y要件が厳しいプロジェクトでは、デザインシステムごとshadcn/uiに寄せるのが最短ルートです。
よくある落とし穴とトラブルシューティング
register(…) を関数として呼んでしまう
// NG: register("name")() は不正
<input {...register("name")()} />
// OK: register("name") は spread して使う
<input {...register("name")} />
数値が文字列で送信される
// NG: 数値型のはずが string になる
<input type="number" {...register("age")} />
// → data.age = "30" (文字列)
// OK: valueAsNumber を指定
<input type="number" {...register("age", { valueAsNumber: true })} />
// → data.age = 30 (数値)
// 日付の場合は valueAsDate
<input type="date" {...register("birthday", { valueAsDate: true })} />
defaultValues 変更が反映されない
// 編集画面で初期値が後から来る場合、resetで反映
useEffect(() => {
if (data) reset(data);
}, [data, reset]);
useFieldArray でkeyにindexを使ってバグる
// NG: index を key にする → 削除でズレる
{fields.map((_, idx) => <input key={idx} ... />)}
// OK: field.id を key にする(RHF発行のUUID)
{fields.map((field, idx) => <input key={field.id} ... />)}
FAQ
Q1. Formikから移行する価値はありますか?
A. あります。Formikはメンテナンスペースが落ちており、Reactの新機能(Server Components等)への追従が遅れています。新規開発ならRHF + Zodの一択、既存Formikは「触る予定があるフォームから順次移行」が現実的です。
Q2. ZodとYupはどちらを選ぶべき?
A. 新規ならZodです。TypeScript統合の質、エコシステムの厚み、サーバー側との型共有のしやすさで圧倒的に上です。既存YupはYupResolverのまま運用継続でも問題ありません。バンドルサイズ重視ならvalibotも有力です。
Q3. registerとControllerはどちらが速いですか?
A. register(非制御)の方が高速です。フィールドが多いほど差が開きます。標準HTML要素はregister、外部UIライブラリのみControllerと割り切ってください。
Q4. Next.js Server Actionsが来たらRHFは不要になりますか?
A. クライアントUXが重要なら依然必要です。Server Actionsはサーバー処理を簡潔にする仕組みで、リアルタイムバリデーション・動的フィールド・プレビュー等のクライアント体験はRHFの方が得意です。両者は共存します。
Q5. useFieldArrayで深い入れ子(items.x.options.y)を扱えますか?
A. 扱えます。useFieldArray({ name: "items" })の中で更にuseFieldArray({ name: `items.${idx}.options` })を呼び出せます。ただし可読性が落ちるため、子コンポーネントに切り出すのが定石です。
Q6. テストで「act warning」が出るのはなぜ?
A. userEventの非同期完了をawaitしていないのが原因です。await user.type(...) / await user.click(...)と必ずawaitしてください。findByText系も同様にawait必須です。
まとめ〜React Hook Formは「型・速度・統合性」で選ぶ
React Hook Formは、非制御コンポーネントで再レンダリングを最小化する設計思想と、Zod / shadcn/ui / Next.js Server Actionsという2026年の主要スタックとの噛み合わせの良さから、もはやReactフォーム実装のデファクトです。本記事で紹介した実装パターンを、新規プロジェクトのテンプレートとしてそのまま使ってもらえれば、フォーム周りの認知コストは大幅に下がるはずです。
次のステップとしては、カスタムフックの作り方を参考に「自社デザインシステム用のRHFラッパー(useControlledTextField等)」を実装すると、プロジェクト全体の生産性がさらに上がります。ZustandやTanStack Queryと組み合わせれば、フォーム以外の状態管理も含めて「型安全・低再描画・サーバー連携自動」な構成が一段と精度高く組めます。React Hook Formを起点に、モダンReactフォーム実装の地力を磨いていきましょう。
本記事では扱いきれなかった「React Testing Libraryによる詳細なフォームテスト戦略」や「Reactパフォーマンス最適化の包括的アプローチ」については、次回以降の記事で改めて深掘りします。フォーム実装で困ったら、まずは公式ドキュメントとこの記事のFAQに立ち返ってみてください。

コメント