React Hook Form(リアクトフックフォーム)は、Reactでフォームの入力・検証・送信を、余分な再描画を抑えて書くためのライブラリです。標準の入力欄を非制御(uncontrolled)で扱い、入力値やフォーム状態の購読範囲を絞ることで、入力のたびにフォーム全体が再描画されるのを抑えられます。
2026年10月27日時点の最新安定版はGitHubのリリース一覧でLatestの7.91.0で、v8は8.0.0-beta.5がプレリリースとして出ている段階です。この記事はv7の書き方で、インストール、registerとhandleSubmit、検証が走るタイミング、Zod連携、watch・useWatch・subscribeの使い分けまでを、手元で動かした結果を添えて説明します。Hooks自体の基礎はReactフック(Hooks)の一覧と使い分けで確認できます。
まとめ:React Hook Formの要点
- 何をするライブラリか:フォームの値の保持・検証・送信をまとめるReact用ライブラリ。依存パッケージは0。
- 導入:
npm install react-hook-formで入るのはv7(7.91.0)。v8はベータで、@betaタグは古い8.0.0-beta.0を指す。 - 基本形:
useForm()からregister・handleSubmit・formStateを取り出し、入力欄に{...register("name")}を展開する。v6のref={register}は動かない。 - 検証のタイミング:既定は送信時に初めて検証し(
mode: "onSubmit")、送信後は入力のたびに再検証する(reValidateMode: "onChange")。 - 値の監視:表示を切り替えるなら
useWatch、保存などの副作用だけならsubscribe。watchは親ごと再描画され、React Compilerとも相性が悪い。 - 向かない場面:入力欄が1〜2個ならuseStateで足りる。React 19のform actionだけで完結する送信にも、まず不要。
React Hook Formの仕組みと現在の版
非制御入力と購読による再描画の抑制
フォームの親コンポーネントで各項目の値をuseStateに保持すると、入力のたびにその親が再描画されます。状態を入力欄の子コンポーネントに置けば、再描画の範囲を狭められます。一方、React Hook Formはregisterが返すrefで入力欄のDOMを直接参照し、入力値はライブラリ内部でも保持します。入力値やエラーなどの購読範囲を絞れば再描画を抑えられますが、親の再描画に伴って子も再描画される場合があります。公式サイトは依存パッケージを持たない点も特徴に挙げています。
v7.91.0とv8ベータの状況
npmのlatestは7.91.0です。v7は2021年4月の7.0.0から5年以上続いており、2026年3月の7.72.0から10月の7.91.0までの7か月でもマイナー版が20回出ています。v8は2025年12月に8.0.0-beta.0へ上がり、2026年10月時点の最新プレリリースは8.0.0-beta.5です。正式版の公開日は公式に示されていません。
注意したいのはインストール方法です。公式のv8移行ガイドにはnpm install react-hook-form@betaと書かれていますが、2026年10月27日時点でnpmのbetaタグは8.0.0-beta.0(2025年12月公開)を指したままで、最新のbeta.5は入りません。v8を試すなら[email protected]と版を指定してください。
v8で書き方が変わる主な点は次のとおりです。v7のコードをそのまま上げると動かなくなる箇所があります。
| 項目 | v7(現行) | v8ベータ |
|---|---|---|
| useFieldArrayの描画用キー | field.id |
field.key |
useFieldArrayのkeyName |
指定できる | 削除 |
watch(callback) |
使える | 削除(subscribeへ) |
公開型のany |
any |
unknown |
Watchコンポーネントのnames |
names |
name |
型定義でも確認できます。7.91.0のFieldArrayWithIdはキー名の既定が'id'、8.0.0-beta.5では'key'です。業務のコードはv7で書き、v8は正式版が出てから8.0.0-beta.1のリリースノートに並ぶ破壊的変更を確認して移るのが安全です。
インストールと最小フォームの作成
npm・yarn・pnpm別のインストールコマンド
peerDependenciesはreactの^16.8.0 || ^17 || ^18 || ^19で、Hooksが使えるReactなら動きます。
npm install react-hook-form
# yarn
yarn add react-hook-form
# pnpm
pnpm add react-hook-form
useFormとregisterによる最小フォームの実装
useForm()が返すregister("フィールド名", ルール)を入力欄にスプレッドし、<form>のonSubmitにはhandleSubmit(onSubmit)を渡します。onSubmitは検証を通ったときだけ、入力値をまとめたオブジェクトを受け取ります。エラーはformState.errorsから取り出します。
import { useForm } from "react-hook-form";
type Inputs = { name: string; email: string };
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Inputs>();
const onSubmit = (data: Inputs) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "名前は必須です" })} />
{errors.name && <span>{errors.name.message}</span>}
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: {
value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
message: "メール形式が正しくありません",
},
})}
/>
{errors.email && <span>{errors.email.message}</span>}
<button type="submit">送信</button>
</form>
);
}
registerの第2引数にはrequired・min・max・minLength・maxLength・pattern・validateを書けます。値をオブジェクトにすると、messageがそのままerrors.フィールド名.messageに入ります。
バリデーションが走るタイミングと数値の扱い
modeとreValidateModeの既定値
「入力した瞬間にエラーが出ない」という戸惑いの多くは既定値の問題です。useFormのAPIリファレンスでは、送信前の検証方法modeの既定がonSubmit、送信後の再検証reValidateModeの既定がonChangeです。
上のContactForm(nameだけ必須にした版)をjsdom上で動かすと、空のまま入力欄からフォーカスを外してもエラーは出ず、送信して初めて「名前は必須です」が表示されました。その後に1文字入力するとエラーは消えます。送信前から検証したいときはmodeを変えます。
| mode | 初回の検証 | 向く画面 |
|---|---|---|
| onSubmit(既定) | 送信時 | 短いフォーム |
| onBlur | フォーカスが外れたとき | 入力途中で責めたくない画面 |
| onTouched | 最初のblur以降は入力ごと | 会員登録など |
| onChange | 入力ごと | 即時の形式チェック |
| all | blurと入力の両方 | 厳密な入力画面 |
const { register } = useForm<Inputs>({
mode: "onTouched", // 一度触った項目から入力ごとに検証
reValidateMode: "onChange",
});
onChangeとallは入力のたびに検証と再描画が走るので、項目が多いフォームでは描画が重くなります。迷ったらonTouchedが無難です。
type=”number”の値の型と数値変換
<input type="number">にregister("age")だけを付けると、20と入力してもonSubmitには文字列の"20"が届きます(実測)。数値で受け取るにはvalueAsNumber: trueを付けます。この場合、空欄はNaNになります。
<input
type="number"
{...register("age", {
valueAsNumber: true,
min: { value: 0, message: "0以上で入力してください" },
})}
/>
ZodとzodResolverによるスキーマ検証
項目が増えてルールが散らばってきたら、スキーマライブラリに検証を集めます。Zodなら@hookform/resolversのzodResolverをuseFormのresolverに渡し、型もz.inferでスキーマから作ります。
@hookform/resolvers 6.0.0のインストールエラー
2026年10月19日に出た@hookform/resolvers 6.0.0は、素のnpm install react-hook-form zod @hookform/resolversがERESOLVEで失敗します。2026年10月27日にnpm 11.17.0の空のプロジェクトで実行しても再現しました。原因は任意のpeer依存effectのバージョン衝突で、resolversのIssue #882で報告され、修正PR #883は10月24日(UTC)にマージ済みですが、10月27日時点でnpmのlatestは6.0.0のままです。修正版が出るまでは5系を指定するのが手早い回避策です。
npm install zod @hookform/resolvers@5
z.coerce.numberによる空欄の0変換
数値項目でよく見かけるz.coerce.number().min(0)は、空欄の""を0に変換してしまいます。zod 4.6.7で{ age: "" }を検証すると結果は{ age: 0 }で、未入力のまま送信が通りました。必須の数値なら、valueAsNumberでNaNにしてz.number()で弾く組み合わせが確実です。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "名前は必須です"),
age: z
.number({ error: "年齢を数値で入力してください" })
.min(0, "0以上で入力してください"),
});
type FormValues = z.infer<typeof schema>;
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit((data) => console.log(data.age + 1))}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">送信</button>
</form>
);
}
このフォームで年齢を空欄のまま送ると「年齢を数値で入力してください」、-1なら「0以上で入力してください」が出て、20なら{ name: "山田", age: 20 }が数値のまま届きました。refineで2項目を比べる方法やZod 4の注意点はReactのZodフォームバリデーション実装、Valibotなど他のスキーマも同じ形で受ける仕組みはStandard Schemaの解説で扱っています。
watch・useWatch・subscribeの使い分け
ここでは入力値の変更を購読するwatch・useWatch・subscribeを、再描画の範囲で比較します。React 19.3.1・React Hook Form 7.91.0・jsdomの検証環境で、初期描画を除き、入力値を3回変更したときの追加の再描画回数を数えました。
| 方法 | useFormを呼ぶ親 | 使う場面 |
|---|---|---|
watch("name") |
3回再描画 | 小さなフォームで手早く |
useWatch(子で呼ぶ) |
0回(子が3回) | 値に応じた表示切り替え |
subscribe |
0回 | 下書き保存・ログ送信 |
subscribeは7.55.0で追加されたAPIで、再描画を起こさずにコールバックだけを呼びます。React Compilerを使っている場合、watchの公式ドキュメントは、watchの参照が変わらないためにコンパイラが結果をキャッシュし、古い値を表示しうると注意しています。コンパイラ適用下ではuseWatchを使うのが公式の案内です。
import { useEffect } from "react";
import { useForm, useWatch, type Control } from "react-hook-form";
type Plan = { plan: "free" | "pro"; note: string };
function PlanNote({ control }: { control: Control<Plan> }) {
const plan = useWatch({ control, name: "plan" }); // 再描画はこの子だけ
return plan === "pro" ? <p>請求先の入力が必要です</p> : null;
}
export function PlanForm() {
const { register, control, subscribe } = useForm<Plan>({
defaultValues: { plan: "free", note: "" },
});
useEffect(() => {
// 再描画せずに下書きを保存する
return subscribe({
name: "note",
formState: { values: true },
callback: ({ values }) => localStorage.setItem("draft-note", values.note),
});
}, [subscribe]);
return (
<form>
<select {...register("plan")}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
<PlanNote control={control} />
<textarea {...register("note")} />
</form>
);
}
setValueによる値の更新とformStateによる状態管理
setValueの再検証オプションと既定動作
住所の自動補完のように、プログラムから値を書き換えるのがsetValueです。送信して「必須」エラーが出ている項目にsetValueだけで値を入れても、エラー表示は消えませんでした。第3引数にshouldValidate: trueを付けると、その場で再検証されて消えます。変更済みとしてisDirtyに反映させたいときはshouldDirty: trueも付けます。
isSubmittingによる送信ボタンの連打防止
handleSubmitにasync関数を渡すと、Promiseが解決するまでformState.isSubmittingがtrueになります。実測でも、送信直後にtrue、完了後にfalseへ戻りました。ボタンのdisabledに渡せば二重押しを防げます。
const {
register,
handleSubmit,
setValue,
formState: { isSubmitting, isDirty },
} = useForm<{ zip: string; address: string }>({
defaultValues: { zip: "", address: "" },
});
const fillAddress = () =>
setValue("address", "東京都千代田区", { shouldValidate: true, shouldDirty: true });
const onSubmit = async (data: { zip: string; address: string }) => {
await fetch("/api/contact", { method: "POST", body: JSON.stringify(data) });
};
// JSX
<button type="submit" disabled={isSubmitting || !isDirty}>送信</button>
ControllerによるMUIなどのUIライブラリとの連携
Material UIのTextFieldのように、値をvalueとonChangeで受け取る制御コンポーネントには、registerを展開する代わりにControllerを使います。renderが受け取るfieldからrefだけを取り出してinputRefへ渡し、残りを展開します。エラーはfieldState.errorで表示します。
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";
export function MuiForm() {
const { control, handleSubmit } = useForm({ defaultValues: { firstName: "" } });
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="firstName"
control={control}
rules={{ required: "必須です" }}
render={({ field: { ref, ...field }, fieldState }) => (
<TextField
{...field}
inputRef={ref}
label="名前"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<button type="submit">送信</button>
</form>
);
}
refを分けずに{...field}をそのまま展開すると、field.refがTextFieldの外側の要素に付き、setFocusや送信時の自動フォーカスでエラー項目の入力欄へ移れなくなります。{...field}にinputRef={field.ref}を足すだけでも、展開されたrefが外側に付くため同じです。詳しい検証はMUIとReact Hook Formの連携方法にまとめています。
useFieldArrayによる入力行の追加・削除
注文明細のように行を増減させるフォームにはuseFieldArrayを使います。公式リファレンスのとおり、各行のkeyには配列の添字ではなくfield.idを渡します。公式は、添字をkeyにすると行の追加・削除で再描画が壊れるとしています。
import { useForm, useFieldArray } from "react-hook-form";
type Order = { items: { product: string; qty: number }[] };
export function OrderForm() {
const { register, control, handleSubmit } = useForm<Order>({
defaultValues: { items: [{ product: "", qty: 1 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit((data) => console.log(data.items))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.product`, { required: true })} />
<input
type="number"
{...register(`items.${index}.qty`, { valueAsNumber: true, required: "数量は必須です", min: 1 })}
/>
<button type="button" onClick={() => remove(index)}>削除</button>
</div>
))}
<button type="button" onClick={() => append({ product: "", qty: 1 })}>
行を追加
</button>
<button type="submit">注文</button>
</form>
);
}
このfield.idはv8でfield.keyに変わる箇所なので、移行時に最初に置き換えることになります。
React Hook Formを使わないほうがよい場面
入れれば必ず得をするライブラリではありません。次の条件に当たるなら、まず使わない構成を検討してください。
- 入力欄が1〜2個で検証がほぼ無い:検索ボックスやニュースレター登録はuseStateで十分です。依存を1つ増やす理由になりません。
- React 19のform actionだけで送信が完結する:サーバー側で検証してエラーを返すだけなら、
useActionStateとFormDataで足ります。書き方はReact 19のform actionの使い方を参照してください。クライアント側の即時検証も欲しくなったら、公式のAdvanced Usageにある、handleSubmitで検証してからstartTransition内でaction関数を呼ぶ併用が使えます。 - 型推論をフォーム全体で厳密に効かせたい:v7の公開型には
anyが残ります。TanStack Formとの違いも比べてから選ぶと後悔が少なくなります。
検証ルールを複数の項目で管理する、入力→確認→送信の画面間で値を共有する、といった要件では、React Hook Formを採用する理由があります。確認画面を挟む実装はReactで確認画面付きフォームを作る方法で解説しています。アンケートや申込フォームを業務システムとして作り込む場合は、弊社のアンケート管理システム開発でも設計から対応しています。
よくある質問
React Hook Formの読み方は?
「リアクトフックフォーム」と読みます。npmのパッケージ名はreact-hook-formで、略して「RHF」と書かれることもあります。
React Hook Formを使うメリットは?
入力欄を非制御で扱い、値や状態の購読範囲を絞ることで、フォーム全体の再描画を抑えられることです。検証・エラー表示・送信中の状態をuseForm1つにまとめられ、依存パッケージも0です。
ref={register} と書くとエラーになるのはなぜ?
v6までの書き方だからです。7.91.0でref={register}のまま入力して送信すると、TypeError: input.split is not a functionで止まりました。v7では{...register("name")}とスプレッドします。
errorsがundefinedになるのはなぜ?
v7ではerrorsをuseForm()の戻り値から直接は取れません。const { errors } = useForm()はundefinedになり、errors.nameでTypeErrorが出ます。formState: { errors }のようにformStateから取り出してください。
React Hook Formのv8はもう使うべき?
2026年10月時点ではベータ(8.0.0-beta.5)なので、本番はv7.91.0のままで問題ありません。試す場合は@betaではなく@8.0.0-beta.5と版を指定してください。