この記事は React Hook Form 公式ドキュメント(Get Started、useForm、register、formState、useController、useFieldArray、handleSubmit)の最新版を一次情報として、実務で「どの場面で・どう書くか」を判断できるところまで踏み込みます。
1. なぜ React Hook Form なのか(非制御 × 再レンダリング最小化)
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: "数値で入力してください" }),
});
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))}>
<input {...register("name")} />
{/* 数値入力は valueAsNumber で number に変換 */}
<input type="number" {...register("age", { valueAsNumber: true })} />
<input type="submit" />
</form>
);
}
設計の起点: RHF は「フォーム状態」を、Zod は「検証ルールと型」を担います。スキーマ1つを**単一の正(Single Source of Truth)**にすれば、型・検証・メッセージが一元化されます(DRY)。Zod 側の書き方は Zod 4 実践ガイド を参照してください。
2. useForm:中核の設定と戻り値
useForm はフォーム1つにつき1回呼び出し、すべての操作の起点になります。
const {
register, // ネイティブ入力を登録
handleSubmit, // 送信ハンドラを生成
control, // Controller / useFieldArray に渡す
formState, // errors / isDirty / isValid / isSubmitting ...
reset, // フォーム全体をリセット
setValue, // プログラムから値を設定
getValues, // 現在値を取得(購読しない)
watch, // 値を購読(再レンダリングを伴う)
setError, // サーバーエラー等を手動設定
trigger, // 手動でバリデーション実行
} = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: { name: "", age: 0 }, // 重要:全フィールドの初期値を与える
mode: "onBlur", // 検証タイミング(下表)
});
2-1. mode:検証タイミングの選択
mode | いつ検証するか | 使いどころ |
|---|---|---|
onSubmit(既定) | 送信時 | 入力中の邪魔をしたくない一般フォーム |
onBlur | フォーカスを外したとき | 「入力し終えたら指摘」する自然なUX |
onTouched | 初回は blur、以降は change | バランス型(おすすめの落としどころ) |
onChange | 入力のたび | 即時フィードバック(再レンダリング増・要注意) |
all | blur と change の両方 | 最も厳格(コスト最大) |
公式も onChange には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。既定は onTouched か onBlur を推奨します。
2-2. defaultValues と values の違い
defaultValues… 初期値。キャッシュされ、resetで更新します。isDirty/dirtyFieldsを正しく出すには全フィールドに与えること(差分の基準になるため)。undefinedを初期値にしないこと。values… リアクティブな値。外部データ(API 取得)で後から上書きしたいときに使います。
// 編集フォーム:API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
defaultValues: { name: "", email: "" },
values: data, // 取得後に上書き
});
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… 任意の変換関数disabled… 入力を無効化(値はundefinedになる)deps… 連動して再検証するフィールド
型安全の注意(公式ルール): 配列はドット記法のみ対応です。
register("items.0.title")は ✅、register("items[0].title")は ❌。またregister("test", {})やregister("test", undefined)でオプションを消すことはできません({ required: false }のように明示します)。
4. formState は Proxy——最大の落とし穴
formState には errors / isDirty / isValid / isSubmitting / isSubmitSuccessful / touchedFields / dirtyFields / isValidating などが入ります。ここが 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 の不可解な「状態が変わらない」バグの大半を回避できます。
5. 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>}
</>
)}
/>
<input type="submit" />
</form>
);
}
field が提供する各プロパティの役割(公式):
| プロパティ | 役割 |
|---|---|
onChange | 値を RHF に送り返す |
onBlur | 入力に触れた(フォーカス/ブラー)ことを通知 |
value | 入力の現在値 |
ref | エラー時にフォーカスを当てる |
name | フィールド名 |
アンチパターン:
registerとController(field)を同じ入力に両方適用しないこと(<input {...field} {...register('x')} />は ❌)。また制御入力ではonChange(undefined)は不正で、nullか空文字を使います。再利用可能な入力コンポーネントを作るなら、フックのuseControllerが便利です。
6. 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>
<input type="submit" />
</form>
);
}
公式が強調する要点:
keyには必ずfield.idを使う(indexは再レンダリングでフィールドが壊れる原因)。append/prepend/insertに渡す値は部分でなく完全な形であること(append({})は ❌)。- 同じ
nameで複数のuseFieldArrayを使わない。shouldUnregister: trueは非対応。
操作 API は append / prepend / insert / remove / move / swap / update / replace が揃っています。
7. handleSubmit:成功・失敗とサーバーエラー
handleSubmit(onValid, onInvalid?) は、検証通過時 onValid(data)、検証失敗時 onInvalid(errors) を呼びます。onValid は async 可。
const onValid = async (data: Schema) => {
try {
await api.submit(data);
} catch (e) {
// 重要:handleSubmit は onValid 内の例外を握りつぶさない。必ず自分で捕捉する
// サーバーエラーは setError でフィールド/フォームに反映する
setError("root.server", { message: "送信に失敗しました。時間をおいて再試行してください。" });
}
};
<form onSubmit={handleSubmit(onValid, (errors) => console.log(errors))} noValidate>
公式の注意:handleSubmit は onValid 内のエラーを飲み込まないため、API 呼び出しは自分で try/catch し、setError でサーバー側エラーを登録します(これにより formState.isSubmitSuccessful も false のままになります)。
送信成功後にサーバー状態(一覧など)を更新したいケースは、フォーム送信を TanStack Query の Mutation に繋ぐのが定石です。楽観的更新やキャッシュ無効化の設計は TanStack Query v5 実践ガイド を参照してください。
8. Zod の .transform() を型安全に扱う(入力型 ≠ 出力型)
Zod 4 では .transform() や z.coerce で入力型と出力型がズレます(Zod 4 実践ガイド参照)。RHF はこれを第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が受け取る、変換後の確定値)
この分離により、「画面は文字列・ロジックは数値」を型の嘘なしで実現できます。
9. アクセシビリティ(a11y):実装すべき3点セット
フォームの a11y は「弾く」だけでなく「何をどう直すかを支援技術にも伝える」ことです。公式例は aria-invalid と role="alert" を用います。実務では**aria-describedby でエラー文を入力に関連付ける**のが理想です。
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 に一本化するとメッセージが一貫します。
10. Next.js App Router での注意("use client" / Server Actions)
useForm / register / Controller はReact フックなので、必ず "use client" のクライアントコンポーネントに置きます(page.tsx 直下に書かない)。
Server Actions と併用する場合は、クライアント側で zodResolver による即時 UX 検証を行いつつ、サーバー側でも同じ Zod スキーマで再検証します(クライアントは信用しない)。スキーマは1つを共有し、両端で適用するのが安全と DRY の両立です。サーバー側検証・整形の作法は Zod 4 実践ガイド に詳述しています。
11. ベストプラクティス&アンチパターン
| やるべきこと(Do) | 避けるべきこと(Don't) |
|---|---|
検証は zodResolver で Zod に委譲(型・検証・文言を一元化) | RHF 組み込みルールと resolver を混在させる |
formState は分割代入で先に読んで購読 | 条件式の中で初めて formState.isValid を参照 |
defaultValues は全フィールド分を与える | 一部だけ/undefined を初期値にして isDirty が壊れる |
制御 UI は Controller、ネイティブは register | 制御コンポーネントを register で繋ごうとする |
useFieldArray の key は field.id | key={index} でフィールドが壊れる |
.transform() は第3ジェネリクスで入出力型を分離 | 変換後の型を as で握りつぶす |
aria-invalid + aria-describedby + role="alert" | エラー文を視覚だけに頼る |
サーバーエラーは setError、API は自分で try/catch | handleSubmit がエラーを処理してくれると誤解 |
| クライアント/サーバーで同じ Zod スキーマを共有 | クライアント検証だけでサーバーを素通し |
12. FAQ(よくある質問)
Q. なぜ useState 直書きより React Hook Form なのですか?
A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。
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; のようにレンダリング前に分割代入して購読してください。条件式の中で初めて参照すると更新されません。
Q. Yup や valibot ではなく Zod を選ぶ理由は?
A. Zod は TypeScript ファーストで型推論が最も自然、エコシステムも厚い(@hookform/resolvers、drizzle-zod、AI SDK の構造化出力など)。スキーマを多用途に使い回せます。詳細は Zod 4 実践ガイド。
Q. 数値や日付がうまく取れません。
A. register("age", { valueAsNumber: true })/{ valueAsDate: true } を指定してください。指定しないと文字列のまま渡ります。
Q. Server Components で useForm が使えません。
A. フックなので "use client" 必須です。サーバー処理は Server Action/ルートハンドラに分け、同じ Zod スキーマで再検証します。
まとめ:フォームは「状態 × 検証 × アクセシビリティ」の合流点
React Hook Form を使いこなす鍵は、フォームを「入力欄の集合」ではなく「状態・検証・アクセシビリティが合流する設計対象」として捉えることです。本記事の柱を振り返ると——
- 非制御 × 再レンダリング最小化で、軽快な大規模フォームを作る。
zodResolverで Zod に検証を委譲し、型・検証・文言を単一スキーマに集約する。formStateの Proxy 購読を理解し、「先に読む」を徹底する。Controller/useFieldArrayで制御 UI と動的フィールドを型安全に扱う。- a11y 3点セットとクライアント/サーバー二重検証で、本番品質に仕上げる。
これらは小手先のテクニックではなく、ユーザー体験・型安全性・保守性を同時に引き上げる「設計の選択」です。正しく適用すれば、入力者の体験(的確な指摘)と開発者の体験(壊れにくさ)の両方が底上げされます。
業務システムの複雑なフォーム設計・既存フォームの a11y/型安全化・パフォーマンス改善が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・保守性・ユーザビリティを重視して設計・実装した過程を紹介しています。