この記事は React Hook Form(以下 RHF)公式ドキュメント——Get Started、useForm、register、formState、useController、useWatch、useFormState、useFieldArray、handleSubmit、Form——の最新版を一次情報とし、さらに型定義のソース(react-hook-form v7.80 系)まで照合して書いています。目的は API の列挙ではなく、実務で「どの場面で・どう書くか」を自分で判断できるところまで踏み込むことです。
検証した版(2026年6月時点):
react-hook-formv7 系(最新 7.80)、@hookform/resolversv5 系、zodv4。v8 は出ていません。@hookform/resolversv5 で Zod 4 と Standard Schema(Valibot / ArkType などを共通インターフェースで扱う仕組み)に正式対応しています。
0. 30秒で全体像:RHF の設計を貫く1本の軸
RHF を「便利なフォームライブラリ」と捉えると細部で迷います。貫く軸は1つ——**「状態の購読を、必要な場所に・必要な分だけ閉じ込める」**です。
- 入力値の保持 … 非制御(
ref)に寄せ、入力中はコンポーネントを再描画しない。 - 検証 …
zodResolverで Zod に委譲し、型・ルール・文言を1スキーマに集約する。 - 状態の購読 …
formState/watch/useWatch/useFormStateは「読んだ場所が再描画される」。だからどこで読むかが設計判断になる。
この3点を意識するだけで、後述の落とし穴(Proxy 購読、watch の再描画爆発、Controller の取り違え)はほぼ回避できます。以降はこの軸を具体化していきます。
1. なぜ RHF なのか、そして「いつ使わないか」
1-1. 非制御 × 再レンダリング最小化
useState でフォームを作ると、1文字入力するたびにコンポーネント全体が再レンダリングされます。フィールドが数個なら無害ですが、数十項目の業務フォームでは入力のたびに全体が再描画され、体感のもたつきと無駄な計算を生みます。
RHF は**非制御コンポーネント(uncontrolled)**を主体に、ref で入力値を直接購読します。公式が掲げる設計思想は明快です——
- 非制御コンポーネントとネイティブ HTML 入力を活用する
- 不要な計算(再レンダリング)を避ける
- 必要なときだけ再レンダリングを隔離する
結果として、入力中はほぼ再レンダリングが発生しません。検証のタイミングも mode で制御でき、コストと UX のバランスを取れます。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const schema = z.object({
name: z.string().min(2, { error: "2文字以上で入力してください" }),
age: z.number({ error: "数値で入力してください" }).int().min(0),
});
type Schema = z.infer<typeof schema>;
function App() {
const { register, handleSubmit } = useForm<Schema>({
resolver: zodResolver(schema), // 検証は Zod に委譲
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input {...register("name")} />
{/* 数値入力は valueAsNumber で number に変換 */}
<input type="number" {...register("age", { valueAsNumber: true })} />
<button>送信</button>
</form>
);
}
設計の起点: RHF は「フォーム状態」を、Zod は「検証ルールと型」を担います。スキーマ1つを**単一の正(Single Source of Truth)**にすれば、型・検証・メッセージが一元化されます(DRY)。Zod 側の書き方は Zod 4 実践ガイド を参照してください。
1-2. いつ RHF を使い、いつ使わないか(意思決定)
「フォーム=とりあえず RHF」は実は早計です。React 19 / Next.js では選択肢が増えました。要件で選びます。
| 状況 | 推奨 | 理由 |
|---|---|---|
| 数項目・JSなし送信を優先・即時検証UX不要 | 素の form + Server Action(useActionState) | 依存ゼロ。プログレッシブに動く |
| 中〜大規模・即時/きめ細かい検証UX・動的フィールド・制御UIライブラリ統合 | React Hook Form | 再描画最小化と検証統合が効く本命 |
| 複数フレームワーク(Solid/Vue 等)も使う・型システム駆動を最重視 | TanStack Form | フレームワーク非依存・型に厳格 |
| Formik を使っている既存資産 | RHF へ移行 | Formik はメンテ停滞・制御主体で再描画が多い |
判断の核は2つ——(1) 即時バリデーションの UX が要るか、(2) 再描画コストが問題になる規模か。両方 No なら素の form で十分。どちらかが Yes なら RHF が最短距離です。
2. useForm:中核の設定と戻り値
useForm はフォーム1つにつき1回呼び出し、すべての操作の起点になります。主要オプションを実務での効き目つきで挙げます。
const {
register, // ネイティブ入力を登録
handleSubmit, // 送信ハンドラを生成
control, // Controller / useFieldArray / useWatch に渡す
formState, // errors / isDirty / isValid / isSubmitting ...(Proxy。後述)
reset, // フォーム全体をリセット
resetField, // 単一フィールドだけリセット
setValue, // プログラムから値を設定
getValues, // 現在値を取得(購読しない=再描画しない)
watch, // 値を購読(呼んだ場所が再描画される)
setError, // サーバーエラー等を手動設定
setFocus, // 任意フィールドにフォーカス
trigger, // 手動でバリデーション実行
getFieldState,// 単一フィールドの状態を取得
} = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: { name: "", age: 0 }, // 重要:全フィールドの初期値を与える
mode: "onTouched", // 検証タイミング(下表)
reValidateMode: "onChange", // 一度エラーになった後の再検証タイミング(既定 onChange)
criteriaMode: "firstError", // "all" にすると1項目の全エラーを収集
shouldFocusError: true, // 送信失敗時に最初のエラー項目へ自動フォーカス(既定 true)
delayError: 0, // エラー表示を N ミリ秒遅延(連打時のチラつき抑制)
});
2-1. mode:検証タイミングの選択
mode | いつ検証するか | 使いどころ |
|---|---|---|
onSubmit(既定) | 送信時 | 入力中の邪魔をしたくない一般フォーム |
onBlur | フォーカスを外したとき | 「入力し終えたら指摘」する自然なUX |
onTouched | 初回は blur、以降は change | バランス型(おすすめの落としどころ) |
onChange | 入力のたび | 即時フィードバック(再レンダリング増・要注意) |
all | blur と change の両方 | 最も厳格(コスト最大) |
公式も onChange には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。既定は onTouched か onBlur を推奨します。なお reValidateMode は「一度エラーが出た後」の再検証タイミングで、初回検証の mode とは別物です。
2-2. defaultValues と values と errors
defaultValues… 初期値。キャッシュされ、resetで更新します。isDirty/dirtyFieldsを正しく出すには全フィールドに与えること(差分の基準になるため)。undefinedを初期値にしないこと。values… リアクティブな値。外部データ(API 取得)で後から上書きしたいときに使います。valuesが変わると内部でreset相当が走り再同期されます。errors… 外部(サーバー)から渡すエラーをリアクティブに反映する入口。valuesと対になる「サーバー由来エラーの宣言的な反映」に使えます。
// 編集フォーム:API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
defaultValues: { name: "", email: "" },
values: data, // 取得後に上書き(リアクティブ)
});
2-3. defaultValues を非同期で読む(編集フォームのローディング)
defaultValues には async 関数も渡せます。「サーバーから取得した値を初期値にする」編集フォームの定石です。読み込み中は formState.isLoading が true になり、初期化が完了すると formState.isReady が true になります——これでスケルトン表示まで宣言的に書けます。
const {
register,
formState: { isLoading, isReady },
} = useForm<UserForm>({
// payload は不要なら省略可。Promise を返すだけ
defaultValues: async () => {
const res = await fetch(`/api/users/${id}`);
return (await res.json()) as UserForm; // 取得値がそのまま初期値に
},
resolver: zodResolver(userSchema),
});
if (isLoading) return <FormSkeleton />; // 取得中はスケルトン
// isReady を使えば「内部の初期化完了」までを厳密に待てる
valuesと asyncdefaultValuesの使い分け: すでに親が取得済みのデータを「流し込む」だけならvalues。フォーム自身に「初期値の取得責務」を持たせ、ローディング UI も内包したいなら asyncdefaultValues。SRP の観点では、データ取得はフォーム外(Server Component や TanStack Query)に置きvaluesで渡すほうが疎結合になりやすいです。
3. register:ネイティブ入力の登録
register("name") は { ref, name, onChange, onBlur } を返し、スプレッドで入力に展開します。ネストや配列もドット記法で表現できます。
<input {...register("name")} /> // { name: value }
<input {...register("address.city")} /> // { address: { city: value } }
<input {...register("items.0.title")} /> // { items: [{ title: value }] }
主なオプション:
valueAsNumber… 値をnumberに変換(数値入力の必須設定)valueAsDate…Dateに変換setValueAs… 任意の変換関数(valueAsNumber等と併用不可)disabled… 入力を無効化(値はundefined扱いになり検証対象から外れる)deps… この項目の検証時に連動して再検証するフィールド(例:確認用パスワード)onChange/onBlur… RHF の登録に加えて自前のハンドラも走らせたいとき
// 「パスワード」を変えたら「確認用」も再検証する
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />
型安全の注意(公式ルール): 配列はドット記法のみ対応です。
register("items.0.title")は ✅、register("items[0].title")は ❌。またregister("test", {})やregister("test", undefined)でオプションを消すことはできません({ required: false }のように明示します)。valueAs*は組み込み検証より先に走るため、Zod 側は変換後の型(number等)を検証する前提で書きます。
4. formState は Proxy——最大の落とし穴
formState には errors / isDirty / isValid / isValidating / isSubmitting / isSubmitSuccessful / isLoading / isReady / submitCount / touchedFields / dirtyFields / validatingFields / defaultValues / disabled が入ります。ここが RHF で最も間違えられるポイントです。
公式の言葉を借りると、formState はレンダリング性能のために Proxy でラップされており、**「購読していない状態は更新ロジックをスキップ」**します。つまり、事前に読み出して購読しないと、その値は更新されません。
// ❌ formState.isValid を条件式の中で初めて参照している
// → Proxy が購読しないため、isValid の変化でボタンが更新されない
return <button disabled={!formState.isDirty || !formState.isValid}>送信</button>;
// ✅ レンダリング前に分割代入して「購読」する
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid}>送信</button>;
同様に useEffect で監視するなら、依存配列には**formState 全体**を入れます(formState.errors 単体ではバッチ更新の都合で発火しないことがある)。
useEffect(() => {
if (formState.isSubmitSuccessful) reset();
}, [formState, reset]); // ✅ formState 全体を依存に
この「先に読む=購読する」を体に入れておくと、RHF の不可解な「状態が変わらない」バグの大半を回避できます。isValid は mode 次第で評価タイミングが変わる点にも注意(onSubmit の場合、送信前は楽観的に true のことがある)。
5. 再レンダリングを「設計」する:watch / useWatch / useFormState / getValues
RHF を選ぶ最大の理由がパフォーマンスなのに、watch の使い方を誤ると再描画が爆発します。ここを設計できるかで実力が出ます。4つの読み取り手段の性格を押さえましょう。
| 手段 | 購読するか | 再描画する場所 | 使いどころ |
|---|---|---|---|
getValues() | しない | しない | 送信時・イベントハンドラ内で値を読むだけ |
watch("x") | する | useForm を呼んだコンポーネント全体 | 小規模。手軽だが巻き込みが大きい |
useWatch({ name }) | する | このフックを呼んだ子だけ | 値の表示を小さな子に隔離する |
useFormState({ control }) | する(formState のみ) | このフックを呼んだ子だけ | 送信ボタンなど formState だけ要る箇所 |
要点は**「読む場所=再描画する場所」**。だから「値を映したい末端」だけを小さなコンポーネントに切り出し、そこで useWatch を呼ぶのが定石です。親(フォーム全体)は再描画しません。
import { useForm, useWatch, type Control } from "react-hook-form";
// ✅ 合計表示だけを子に隔離。明細を打っても再描画されるのは <Total> のみ
function Total({ control }: { control: Control<InvoiceForm> }) {
const items = useWatch({ control, name: "items" });
const total = items.reduce((sum, i) => sum + (i.price ?? 0) * (i.qty ?? 0), 0);
return <output>{total.toLocaleString()} 円</output>;
}
function InvoiceForm() {
const { register, control, handleSubmit } = useForm<InvoiceForm>({
defaultValues: { items: [{ price: 0, qty: 1 }] },
});
return (
<form onSubmit={handleSubmit(save)}>
{/* ...明細入力... */}
<Total control={control} /> {/* ここだけ再描画される */}
</form>
);
}
同様に、送信ボタンの活性だけ isValid / isDirty を見たいなら、ボタンを子に切り出して useFormState を使えば、親フォームは入力中まったく再描画しません。
import { useFormState } from "react-hook-form";
function SubmitButton({ control }: { control: Control<Schema> }) {
const { isDirty, isValid, isSubmitting } = useFormState({ control });
return (
<button disabled={!isDirty || !isValid || isSubmitting}>
{isSubmitting ? "送信中…" : "送信"}
</button>
);
}
アンチパターン:
const values = watch();(引数なし=全フィールド購読)をuseForm直下で呼ぶと、1文字ごとにフォーム全体が再描画され、RHF を使う意味が半減します。「全体を見たい」ときも、表示する末端にuseWatchを下ろすのが正解です。
6. Controller / useController:制御 UI ライブラリの統合
shadcn/ui・MUI・Ant Design・React-Select のような制御コンポーネントは ref で値を取れないため、register では繋がりません。ここで Controller(または useController)を使います。
import { useForm, Controller } from "react-hook-form";
import ReactDatePicker from "react-datepicker";
function App() {
const { control, handleSubmit } = useForm<{ publishedAt: Date }>();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
control={control}
name="publishedAt"
render={({ field: { onChange, onBlur, value, ref }, fieldState }) => (
<>
<ReactDatePicker onChange={onChange} onBlur={onBlur} selected={value} ref={ref} />
{fieldState.error && <p role="alert">{fieldState.error.message}</p>}
</>
)}
/>
<button>送信</button>
</form>
);
}
field が提供する各プロパティの役割(公式):
| プロパティ | 役割 |
|---|---|
onChange | 値を RHF に送り返す |
onBlur | 入力に触れた(フォーカス/ブラー)ことを通知 |
value | 入力の現在値 |
ref | エラー時にフォーカスを当てる |
name | フィールド名 |
disabled | フォーム全体/個別の無効化状態 |
fieldState からは invalid / isDirty / isTouched / error が取れ、そのフィールド単独の状態を a11y 属性にそのまま流せます。
アンチパターン:
registerとController(field)を同じ入力に両方適用しないこと(<input {...field} {...register('x')} />は ❌)。また制御入力ではonChange(undefined)は不正で、nullか空文字を使います。再利用可能な入力コンポーネントを作るなら、フックのuseControllerが便利です(次節)。
7. FormProvider × useController:型安全な「再利用フィールド」を作る
実務のフォームは「同じ見た目のフィールドが何十個も並ぶ」もの。各フィールドに register・label・aria-*・エラー表示をベタ書きすると、DRY も a11y も崩れます。FormProvider でフォーム文脈を配り、useController で1フィールドを部品化するのが正攻法です。型は FieldValues と Path<T> で安全に保ちます。
"use client";
import {
useController,
useFormContext,
type FieldValues,
type Path,
} from "react-hook-form";
type TextFieldProps<T extends FieldValues> = {
name: Path<T>; // ✅ そのスキーマに存在するキーしか渡せない(型安全)
label: string;
type?: React.HTMLInputTypeAttribute;
};
// SRP:このコンポーネントの責務は「1つの入力+ラベル+エラー+a11y」だけ
export function TextField<T extends FieldValues>({
name,
label,
type = "text",
}: TextFieldProps<T>) {
const { control } = useFormContext<T>();
const { field, fieldState } = useController<T>({ name, control });
const errorId = `${name}-error`;
return (
<div className="field">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
aria-invalid={fieldState.invalid}
aria-describedby={fieldState.error ? errorId : undefined}
{...field}
/>
{fieldState.error && (
<p id={errorId} role="alert">
{fieldState.error.message}
</p>
)}
</div>
);
}
使う側はこれだけ。FormProvider で methods を配るので、子フィールドに control をプロップで渡し続ける必要がありません(バケツリレーの解消)。
"use client";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function SignupForm() {
const methods = useForm<Schema>({ resolver: zodResolver(schema) });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onValid)} noValidate>
<TextField<Schema> name="email" label="メールアドレス" type="email" />
<TextField<Schema> name="name" label="氏名" />
<SubmitButton control={methods.control} />
</form>
</FormProvider>
);
}
この設計が同時に解く課題:
- DRY … a11y 属性・エラー表示・id 連番のロジックを1か所に集約。
- ETC(変えやすさ) … デザインシステム差し替えは
TextFieldの中だけ。 - パフォーマンス …
useControllerはそのフィールドの変化でだけ再描画されるため、入力が他フィールドを巻き込みません(§5 の隔離が部品レベルで効く)。 - 型安全 …
nameはPath<T>なので、存在しないキーや打ち間違いはコンパイルエラー。
8. useFieldArray:動的フィールド(明細・タグ・複数連絡先)
「請求明細を行で追加」「タグを可変個」——配列項目の動的な追加・削除は useFieldArray の出番です。
import { useForm, useFieldArray } from "react-hook-form";
function InvoiceForm() {
const { register, control, handleSubmit } = useForm<{
items: { name: string; price: number }[];
}>({ defaultValues: { items: [{ name: "", price: 0 }] } });
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit((d) => console.log(d))}>
{fields.map((field, index) => (
// ✅ key は必ず field.id(index は不可)
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(index)}>削除</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", price: 0 })}>明細を追加</button>
<button>送信</button>
</form>
);
}
公式が強調する要点:
keyには必ずfield.idを使う(indexは再レンダリングでフィールドが壊れる原因)。append/prepend/insertに渡す値は部分でなく完全な形であること(append({})は ❌)。- 同じ
nameで複数のuseFieldArrayを使わない。useFieldArrayではshouldUnregister: trueは非対応。
操作 API は append / prepend / insert / remove / move / swap / update / replace が揃っています。明細の合計をリアルタイム表示するなら、§5 のとおり useWatch を末端の <Total> に下ろして再描画を隔離してください。
9. handleSubmit:成功・失敗・サーバーエラーと「二重送信」対策
handleSubmit(onValid, onInvalid?) は、検証通過時 onValid(data)、検証失敗時 onInvalid(errors) を呼びます。onValid は async 可。
const {
handleSubmit,
setError,
formState: { isSubmitting, errors },
} = useForm<Schema>({ resolver: zodResolver(schema) });
const onValid = async (data: Schema) => {
try {
await api.submit(data);
} catch (e) {
// 重要:handleSubmit は onValid 内の例外を握りつぶさない。必ず自分で捕捉する
// サーバーエラーはフォーム全体エラーとして root に積む
setError("root.server", {
message: "送信に失敗しました。時間をおいて再試行してください。",
});
}
};
return (
<form onSubmit={handleSubmit(onValid, (errs) => console.log(errs))} noValidate>
{/* ...入力... */}
{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
{/* ✅ 送信中はボタンを無効化=二重送信を構造的に防ぐ(冪等性の入口) */}
<button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
</form>
);
公式の注意:handleSubmit は onValid 内のエラーを飲み込まないため、API 呼び出しは自分で try/catch し、setError でサーバー側エラーを登録します(これにより formState.isSubmitSuccessful も false のままになります)。
信頼性の観点で2つ徹底します:
- 二重送信防止 …
disabled={isSubmitting}で送信中はボタンを止める。ネットワーク遅延中の連打を UI で封じ、サーバー側でも冪等キーで二重実行を弾く(決済では必須。冪等性の設計 参照)。 - フィールド単位のサーバーエラー … 「このメールは既に使われています」のような項目別エラーは
setError("email", { message })で該当フィールドに戻し、shouldFocusでそこへフォーカスさせる。
送信成功後にサーバー状態(一覧など)を更新したいケースは、フォーム送信を TanStack Query の Mutation に繋ぐのが定石です。楽観的更新やキャッシュ無効化の設計は TanStack Query v5 実践ガイド を参照してください。
10. 入力型 ≠ 出力型:.transform() を第3ジェネリクスで型安全に
Zod 4 では .transform() や z.coerce で入力型と出力型がズレます(Zod 4 実践ガイド参照)。RHF はこれを第3ジェネリクスで受け止めます。useForm<TFieldValues, TContext, TTransformedValues> という3引数が、まさにこの「変換前/変換後」を表現します。
import * as z from "zod";
const schema = z.object({
// フォーム上は文字列、送信後は number にしたい
price: z.string().transform((s) => Number.parseInt(s, 10)),
});
type FormInput = z.input<typeof schema>; // { price: string } ← register が扱う型
type FormOutput = z.output<typeof schema>; // { price: number } ← handleSubmit が受け取る型
const { register, handleSubmit } = useForm<FormInput, unknown, FormOutput>({
resolver: zodResolver(schema),
});
handleSubmit((data) => {
data.price; // number として型がつく(変換後)
});
- 第1ジェネリクス … 入力型(
register/watchが扱う、変換前のフォーム値) - 第3ジェネリクス … 出力型(
handleSubmitが受け取る、変換後の確定値)
この分離により、「画面は文字列・ロジックは数値」を型の嘘なしで実現できます。なお zodResolver は第2引数で挙動を調整でき、zodResolver(schema, undefined, { raw: true }) とすると変換前の生値を返します(フォームには生値を保持し、変換は送信後に別途行いたい場合)。Valibot / ArkType など他ライブラリでも、standardSchemaResolver を使えば同じ型分離の恩恵を受けられます。
11. アクセシビリティ(a11y):実装すべき3点セット
フォームの a11y は「弾く」だけでなく「何をどう直すかを支援技術にも伝える」ことです。公式例は aria-invalid と role="alert" を用います。実務では**aria-describedby でエラー文を入力に関連付ける**のが理想です(§7 の TextField はこれを内蔵しています)。
const { register, formState: { errors } } = useForm<Schema>({ resolver: zodResolver(schema) });
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
3点セット:
aria-invalid… エラー状態を支援技術へ伝える。aria-describedby… エラー文(id付き)を入力に紐づけ、フォーカス時に読み上げさせる。role="alert"… エラー出現を即時に通知。
加えて、shouldFocusError(既定 true)が送信失敗時に最初のエラー項目へ自動フォーカスします。フォームには noValidate を付け、検証 UI を RHF/Zod に一本化するとメッセージが一貫します。a11y を WCAG 2.2 まで含めて体系化したい場合は React/Next.js アクセシビリティ実装ガイド を参照してください。
12. Next.js App Router:"use client" / Server Actions / <Form> / プログレッシブ
12-1. 置き場所の鉄則
useForm / register / Controller はReact フックなので、必ず "use client" のクライアントコンポーネントに置きます(page.tsx 直下に書かない)。ページ(Server Component)はデータ取得とレイアウトに専念し、フォーム本体を子のクライアントコンポーネントに分離します。
12-2. Server Actions との「二重検証」が本番の型
Server Actions と併用する場合の鉄則は——クライアント側で zodResolver による即時 UX 検証を行いつつ、サーバー側でも同じ Zod スキーマで再検証することです(クライアントは信用しない)。スキーマは1つを共有し、両端で適用するのが安全と DRY の両立です。
// schema.ts —— クライアント/サーバー共有の単一スキーマ
export const contactSchema = z.object({
email: z.email({ error: "メールアドレスを正しく入力してください" }),
message: z.string().min(10, { error: "10文字以上で入力してください" }),
});
export type Contact = z.infer<typeof contactSchema>;
// actions.ts —— サーバーでも必ず再検証する
"use server";
import * as z from "zod";
import { contactSchema } from "./schema";
export async function submitContact(input: unknown) {
const parsed = contactSchema.safeParse(input); // 信頼境界はここ
if (!parsed.success) {
// フィールド別エラーを構造化して返す
return { ok: false as const, errors: z.flattenError(parsed.error).fieldErrors };
}
await db.contacts.insert(parsed.data);
return { ok: true as const };
}
// contact-form.tsx —— クライアントは即時UX、サーバー結果を setError に反映
"use client";
const onValid = async (data: Contact) => {
const res = await submitContact(data);
if (!res.ok) {
// サーバーが返した項目別エラーを RHF に流し込む(信頼の源はサーバー)
for (const [name, messages] of Object.entries(res.errors)) {
if (messages?.[0]) setError(name as keyof Contact, { message: messages[0] });
}
}
};
サーバー側のバリデーション・整形の作法は Zod 4 実践ガイド に詳述しています。
12-3. RHF の <Form> コンポーネントとプログレッシブ・エンハンスメント
RHF v7 には送信を一手に引き受ける <Form> コンポーネントがあります。action を指定すると検証 → fetch で POSTまで面倒を見て、結果に応じて onSuccess / onError を呼びます。action を省略すればネイティブ送信になり、JS が無効でも動く土台になります。
import { useForm, Form } from "react-hook-form";
function Newsletter() {
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(contactSchema),
});
return (
<Form
action="/api/contact" // 指定で fetch POST/省略でネイティブ送信
method="post"
control={control}
onSuccess={() => {/* 2xx */}}
onError={() => setError("root.server", { message: "送信に失敗しました" })}
>
<label htmlFor="email">メール</label>
<input id="email" {...register("email")} aria-invalid={!!errors.email} />
<button>登録</button>
</Form>
);
}
さらに useForm({ progressive: true }) + shouldUseNativeValidation: true を使うと、required / min などのネイティブ検証属性が出力され、JS 到達前でもブラウザ標準の検証が効きます。「最初の描画から壊れていない」フォームを目指す Next.js のサーバー中心設計と相性が良い選択肢です。
13. テスト容易性:フォームのユニットテスト
RHF + Zod は検証を1スキーマに集約するため、**テストは「描画 → 操作 → 表示・送信を検証」**の形に素直に落ちます。@testing-library/react + @testing-library/user-event で、ロール/ラベル経由に操作すると a11y まで同時に検証できます(getByLabelText が通る=ラベルが正しく結びついている証拠)。
// contact-form.test.tsx (jsdom 環境)
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ContactForm } from "./contact-form";
describe("ContactForm", () => {
it("空送信は onSubmit を呼ばず、エラーを読み上げ領域に出す", async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await userEvent.click(screen.getByRole("button", { name: "送信" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"メールアドレスを正しく入力してください",
);
expect(onSubmit).not.toHaveBeenCalled();
});
it("妥当な入力で整形済みデータを onSubmit に渡す", async () => {
const onSubmit = vi.fn();
render(<ContactForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText("メールアドレス"), "a@example.com");
await userEvent.type(screen.getByLabelText("お問い合わせ"), "詳しい資料が欲しいです");
await userEvent.click(screen.getByRole("button", { name: "送信" }));
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
email: "a@example.com",
message: "詳しい資料が欲しいです",
}),
);
});
});
ポイントは**「実装の内部状態をテストしない」**こと。formState.isValid を直接読むのではなく、ユーザーが見るもの(エラーメッセージ・送信の発火)を検証します。これにより、RHF の内部や mode を変えてもテストが壊れにくくなります(リファクタ耐性)。E2E まで含めた設計は Playwright E2E 実践ガイド を参照してください。
14. 実務で効くその他 API 早見表
| API | 何をするか | 典型的な使いどころ |
|---|---|---|
reset(values?, options?) | フォーム全体を初期化/指定値で再設定 | 送信成功後・編集キャンセル。keepDirtyValues 等で保持範囲を制御 |
resetField(name) | 単一フィールドだけ初期化 | 「この項目だけ元に戻す」 |
setFocus(name) | 任意フィールドへフォーカス | ステップ移動・エラー誘導 |
getFieldState(name) | 単一フィールドの状態を取得 | 個別の invalid / isDirty 判定 |
trigger(name?) | 手動で検証を発火 | ウィザードの「次へ」で当該ステップだけ検証 |
unregister(name) | 登録解除 | 条件付き表示フィールドの後始末 |
disabled(useForm) | フォーム全体を無効化 | 送信中に全入力をロック |
createFormControl() | コンポーネント外でフォーム制御を生成 | 描画に依存せず状態を読む・ウィザード横断 |
createFormControl は少し上級です。useForm の内部実装を外に取り出すもので、生成した formControl を useForm({ formControl }) に渡せば、コンポーネントの再描画に依存せずフォーム状態を読み書きできます。複数ステップにまたがる巨大ウィザードや、フォーム外のロジックから状態を監視したいケースで効きます。まずは FormProvider で足りないか検討し、本当に必要なときだけ使ってください(YAGNI)。
15. ベストプラクティス&アンチパターン
| やるべきこと(Do) | 避けるべきこと(Don't) |
|---|---|
検証は zodResolver で Zod に委譲(型・検証・文言を一元化) | RHF 組み込みルールと resolver を混在させる |
formState は分割代入で先に読んで購読 | 条件式の中で初めて formState.isValid を参照 |
defaultValues は全フィールド分を与える | 一部だけ/undefined を初期値にして isDirty が壊れる |
値の表示は末端で useWatch、ボタンは useFormState | useForm 直下で引数なし watch() し全体を再描画 |
制御 UI は Controller/useController、ネイティブは register | 制御コンポーネントを register で繋ごうとする |
再利用フィールドは FormProvider + Path<T> で型安全に部品化 | 各フィールドに register・aria-* をベタ書きで重複 |
useFieldArray の key は field.id | key={index} でフィールドが壊れる |
.transform() は第3ジェネリクスで入出力型を分離 | 変換後の型を as で握りつぶす |
aria-invalid + aria-describedby + role="alert" | エラー文を視覚だけに頼る |
送信中は disabled/isSubmitting で二重送信を封じる | 連打でリクエストが多重発火する |
サーバーエラーは setError、API は自分で try/catch | handleSubmit がエラーを処理してくれると誤解 |
| クライアント/サーバーで同じ Zod スキーマを共有 | クライアント検証だけでサーバーを素通し |
16. FAQ(よくある質問)
Q. なぜ useState 直書きより React Hook Form なのですか?
A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。逆に数項目なら素の form + Server Action で十分なこともあります(§1-2)。
Q. register と Controller の使い分けは?
A. ネイティブ HTML 入力(input / select / textarea)は register、ref で値を取れない制御 UI ライブラリ(shadcn/MUI/React-Select 等)は Controller/useController です。
Q. isValid でボタンの活性を切り替えると動きません。
A. formState が Proxy だからです。const { isValid } = formState; のようにレンダリング前に分割代入して購読してください。条件式の中で初めて参照すると更新されません(§4)。
Q. 入力が重い・1文字ごとにフォーム全体が再描画されます。
A. watch() をフォーム直下で呼んでいないか確認を。表示は末端の useWatch、ボタンは useFormState に隔離し、再利用フィールドは useController で部品化すると、再描画がそのフィールドに閉じます(§5・§7)。
Q. 編集フォームで API から初期値を入れたい。
A. 親が取得済みなら values で流し込み、フォーム自身に取得責務を持たせるなら async defaultValues + isLoading/isReady でスケルトン表示します(§2-3)。
Q. Server Components で useForm が使えません。
A. フックなので "use client" 必須です。サーバー処理は Server Action/ルートハンドラに分け、同じ Zod スキーマで再検証します(§12)。
Q. Yup や valibot ではなく Zod を選ぶ理由は?
A. Zod は TypeScript ファーストで型推論が最も自然、エコシステムも厚い(@hookform/resolvers、drizzle-zod、AI SDK の構造化出力など)。valibot/ArkType を使いたい場合も standardSchemaResolver で同様に繋げます。詳細は Zod 4 実践ガイド。
Q. 数値や日付がうまく取れません。
A. register("age", { valueAsNumber: true })/{ valueAsDate: true } を指定してください。指定しないと文字列のまま渡ります(§3)。
まとめ:フォームは「状態 × 検証 × アクセシビリティ」の合流点
React Hook Form を使いこなす鍵は、フォームを「入力欄の集合」ではなく「状態・検証・アクセシビリティが合流する設計対象」として捉えることです。本記事の柱を振り返ると——
- 非制御 × 再レンダリング最小化で、軽快な大規模フォームを作る。
zodResolverで Zod に検証を委譲し、型・検証・文言を単一スキーマに集約する。formStateの Proxy 購読を理解し、「先に読む」を徹底する。watch/useWatch/useFormStateで再描画を「読む場所」に閉じ込め、FormProvider+useControllerで型安全な再利用フィールドを作る。Controller/useFieldArrayで制御 UI と動的フィールドを扱い、a11y 3点セット・二重送信防止・クライアント/サーバー二重検証で本番品質に仕上げる。
これらは小手先のテクニックではなく、ユーザー体験・型安全性・保守性・パフォーマンスを同時に引き上げる「設計の選択」です。正しく適用すれば、入力者の体験(的確な指摘)と開発者の体験(壊れにくさ)の両方が底上げされます。
業務システムの複雑なフォーム設計・既存フォームの a11y/型安全化・パフォーマンス改善が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・保守性・ユーザビリティを重視して設計・実装した過程を紹介しています。