React Hook Form × Zod の実践パターンと、register で足りる/Controller が要る境界

目次
React Hook Form(RHF) と Zod を組み合わせてフォームを作るとき、最初はうまく動くのに「外部の入力コンポーネントを差し込んだ途端に値が取れない」で詰まりがちです。原因は register の spread で足りるケースと、Controller でラップしないと対応できないケースの境界を押さえていないこと。
この記事では、ログイン・新規登録フォームを題材に、
zodResolverで RHF と Zod をつなぐz.inferでスキーマから型を導出する.refineでパスワード一致をバリデーションするformState.errorsを分割代入で短く書くregisterの spread がなぜ非制御コンポーネントで効くのか- どういうときに
Controllerが必要になるのか
を実際のコード順にまとめます。環境は Next.js(App Router) / TypeScript / @hookform/resolvers / Zod v4 です。
インストール
pnpm add react-hook-form zod @hookform/resolvers@hookform/resolvers は RHF と各バリデーションライブラリ(Zod など)を橋渡しするアダプタ集です。Zod 本体とは別パッケージなので忘れずに入れます。
1. Zod スキーマを先に定義する
フォームの「正しさ」をまず Zod スキーマで宣言します。ログインはシンプルに email + password。
import { z } from "zod";
const loginSchema = z.object({
email: z.email("メールアドレスの形式が正しくありません"),
password: z.string().min(1, "パスワードを入力してください"),
});Zod v4 からトップレベルの
z.email()が推奨になりました(v3 のz.string().email()は非推奨)。
2. z.infer でスキーマから型を導出する
スキーマと別に TypeScript の型を手書きすると二重管理になります。z.infer を使えばスキーマが唯一の真実になり、型は自動で追従します。
type LoginValues = z.infer<typeof loginSchema>;
// => { email: string; password: string }3. zodResolver で useForm につなぐ
useForm の resolver に zodResolver(スキーマ) を渡すだけです。これで送信時(と設定に応じて入力時)に Zod のバリデーションが走り、失敗すると formState.errors にメッセージが入ります。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});useForm<LoginValues> と型引数を渡しておくと、register("email") のフィールド名補完や handleSubmit の引数型が効きます。
4. .refine で「フィールドをまたぐ」バリデーション
新規登録では「パスワードと確認用パスワードの一致」をチェックしたくなります。これは 1 フィールド単体では表現できないので、.object() 全体に .refine() を掛けます。
const signUpSchema = z
.object({
email: z.email("メールアドレスの形式が正しくありません"),
password: z.string().min(8, "パスワードは8文字以上で入力してください"),
passwordConfirm: z.string(),
})
.refine((v) => v.password === v.passwordConfirm, {
message: "パスワードが一致しません",
path: ["passwordConfirm"], // エラーをこのフィールドに紐付ける
});ポイントは path。これを指定すると、エラーが errors.passwordConfirm に入るので、確認用フィールドの真下にメッセージを出せます。省略するとフォーム全体のルートエラーになり、どの欄の問題か UI 上で示しにくくなります。
5. formState.errors は分割代入で短く
form.formState.errors.email?.message は毎回書くと長い。RHF 公式でも formState の分割代入が推奨で、Proxy による購読最適化(必要な state だけ再レンダ対象にする)も効きます。
const { errors } = form.formState;
// 各フィールドで
<input {...form.register("email")} />
{errors.email && <p className="text-red-600">{errors.email.message}</p>}6. register の spread と、フォーム全体
register("email") は入力欄を RHF に登録するためのオブジェクトを返します。中身はこうです。
form.register("email")
// => { name, onChange, onBlur, ref }これを spread で input にまとめて渡します。
const onSubmit = form.handleSubmit((values) => {
// values は LoginValues 型。バリデーション通過済み
console.log(values.email, values.password);
});
return (
<form onSubmit={onSubmit}>
<input type="email" {...form.register("email")} />
<input type="password" {...form.register("password")} />
<button type="submit">ログイン</button>
</form>
);register が「非制御コンポーネント」で効く理由
register が返す ref / onChange / onBlur は、そのままネイティブ <input> に渡ることを前提にしています。RHF は
refで DOM 要素そのものを掴み(非制御=uncontrolled)、- 値の変化は
onChangeに渡ってくる DOM イベント(e.target.value) から読む
という仕組みだからです。つまり register の spread が成立する条件は「ref を DOM に転送でき、onChange が DOM の event を渡すコンポーネント」であること。
自作のラッパーコンポーネントでも、内部で ...props を素の <input> に流していればこの条件を満たします。
// props をそのまま <input> に素通しする自作 Input
export const Input = ({ label, error, id, className, ...props }) => {
// ...
return (
<div>
{label && <label htmlFor={id}>{label}</label>}
{/* register() の name/onChange/onBlur/ref が ...props 経由で input に届く */}
<input id={id} className={className} {...props} />
{error && <p className="text-red-600">{error}</p>}
</div>
);
};こう作っておけば、パスワード表示トグル付きの PasswordInput のような派生コンポーネントでも、使う側は変わらず spread でつなげます。
<Input label="メールアドレス" type="email" {...form.register("email")} />
<PasswordInput label="パスワード" {...form.register("password")} />Controller が必要になるケース
問題は、外部 UI ライブラリの入力コンポーネントが上の条件を満たさないときです。以下のいずれかに当てはまると register の spread では動かず、Controller でラップする必要が出ます。
| 状況 | register だと何が困るか |
|---|---|
値が e.target.value で来ない |
セレクトや日付ピッカーが onChange={(value) => ...} や onChange={(date: Date) => ...} の形。RHF は e.target.value を期待するので値を拾えない |
ref を DOM に転送できない |
ライブラリが内部で ref を握っていて、外から DOM を非制御として登録できない |
| 完全な制御コンポーネント | value と onChange を必ずセットで要求し、非制御(ref ベース)で動かせない設計 |
| 値が string でない | 複数選択の配列、オブジェクト、Date など。RHF の値と UI の値のあいだに変換が要る |
具体的には、リッチなセレクト(React Select など)、日付/時刻ピッカー、スライダー、リッチテキストエディタ、UI ライブラリの Autocomplete 系が該当しがちです。
Controller は、RHF が管理する field(value / onChange / onBlur / ref)を render prop で受け取り、それを各ライブラリ流の props に橋渡しします。
import { Controller } from "react-hook-form";
<Controller
control={form.control}
name="country"
render={({ field, fieldState }) => (
<ReactSelect
options={options}
// RHF の値(string) → ライブラリが期待する option オブジェクトへ
value={options.find((o) => o.value === field.value) ?? null}
// ライブラリの onChange(option) → RHF が期待する値へ変換
onChange={(opt) => field.onChange(opt?.value ?? "")}
onBlur={field.onBlur}
ref={field.ref}
/>
)}
/>;やっていることは「値の形と onChange の作法を、RHF ↔ ライブラリのあいだで翻訳する」だけです。fieldState.error?.message でエラーメッセージも取れます。
判断フロー
迷ったら次で切り分けられます。
- その入力の最終的な実体が素の
<input>/<select>/<textarea>で、refとonChange(event)を素通しできる? → Yes:registerの spread でOK(自作の非制御コンポーネント含む) - 外部ライブラリで、値が
e.target.valueで来ない / ref を DOM に渡せない / 制御必須? →Controllerでラップして翻訳する
自作の薄いラッパーは 1 に寄せて作っておくと、register だけで一貫して書けて楽になります。Controller は「外部ライブラリを噛ませたときの変換レイヤ」と捉えると、使いどころがはっきりします。
まとめ
zodResolver(スキーマ)をuseFormに渡すだけで Zod と RHF がつながる- 型は
z.inferでスキーマから導出し、二重管理を避ける - フィールドをまたぐ検証(パスワード一致など)は
.refine+pathで該当欄に紐付ける formState.errorsは分割代入で短く・購読最適化も効くregisterの spread は「ref を DOM に転送でき、onChange が event を渡す」非制御コンポーネントで成立する- 外部ライブラリで値の形や onChange の作法が違うときは
Controllerで翻訳する
参考ソース
関連記事

Firebase Cloud Functions の onRequest(HTTP) と onCall(callable) を、認証・CORS・エラー処理・ストリーミングの観点で比較します。「アプリ内の本人確認が要る処理は onCall、監視・Webhook・ストリーミングなど外向き/特殊なものだけ onRequest」という選定基準に落とし込みます。

pnpm製プロジェクトで、ターミナルのESLintは動くのにエディタ上では警告が一切出ない。原因はプラグインのhoist、決め手は拡張のログだった。.npmrcでの解決とモノレポでの設定まで。

TanStack Query を Next.js App Router の静的エクスポート構成に入れる手順を、Providerの初期化(use client)→layoutラップ→共通fetch(lib/api.ts)→hooks(1ファイル1フック)→コンポーネントでの使い方、まで一気通貫でまとめます。