React Hook Form × Zodで実務に耐えるフォーム実装を作る
はじめに
React Hook Form(以下RHF)を使い始めて、フォーム実装が一気に楽になった──
これはRHFを使い始めた人なら誰しもが感じることだと思います。
useForm と register でサクッと入力値を扱えるのは便利。
でも、こんな課題を感じたことはありませんか?
- バリデーションルールを毎回フォーム内で直書きしていて読みにくい
- 型定義とバリデーションが別々になっていて管理が面倒
- ちょっと複雑な条件(例:パスワード一致チェック)を書くと急に混乱する
そんなときに便利なのが Zod。
RHFとZodを組み合わせることで、
型とバリデーションを1つのスキーマにまとめて管理できるようになります。
今回は、私が実務でも使用して「これならスッキリ書ける!」と感じた
React Hook Form + Zod構成の基本を紹介します。
1. まずは必要なパッケージをインストール
npm install react-hook-form zod @hookform/resolvers
RHFとZodをつなぐのが @hookform/resolvers です。
zodResolver という関数が含まれており、Zodで定義したスキーマをRHFで使えるようにしてくれます。
2. Zodでスキーマを定義する
まずは、入力ルールをZodで定義します。
import { z } from "zod";
export const formSchema = z.object({
email: z.email("正しいメールアドレスを入力してください"),
age: z.coerce.number().min(18, "18歳以上である必要があります"),
password: z.string().min(8, "8文字以上で入力してください"),
});
export type FormValues = z.infer<typeof formSchema>;
ここでのポイントは2つ。
- z.coerce.number():文字列でも自動的に数値に変換してくれる
- z.infer:スキーマから自動で型を生成できる
このほかにも様々な型を扱えます。詳しくは以下を参照。
このスキーマを使えば、型とバリデーションルールが常に一致します。
「ルールを変えたのに型を直し忘れた」みたいな事故を防げます。
3. RHFにzodResolverを組み合わせる
まずは、RHF単体の場合のコードから見てみましょう。
RHF単体でのフォーム実装
import { useForm } from "react-hook-form";
type FormValues = {
email: string;
age: number;
password: string;
};
export const UserForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
email: "",
age: 20,
password: "",
},
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "正しいメールアドレスを入力してください",
},
})}
placeholder="メールアドレス"
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="number"
{...register("age", {
valueAsNumber: true,
min: { value: 18, message: "18歳以上である必要があります" },
})}
placeholder="年齢"
/>
{errors.age && <p>{errors.age.message}</p>}
<input
type="password"
{...register("password", {
required: "パスワードは必須です",
minLength: { value: 8, message: "8文字以上で入力してください" },
})}
placeholder="パスワード"
/>
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">送信</button>
</form>
);
};
一見問題なさそうに見えますが、
- バリデーションルールがフォームの中に直書きされていて読みにくい
-
FormValuesの型と実際のルールが別管理になっている
という欠点があります。
フォーム項目が増えてくると、コードがどんどん煩雑になっていきます。
Zodを組み合わせた場合
次に、同じフォームを Zod + zodResolver で書き換えた例を見てみましょう。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email("正しいメールアドレスを入力してください"),
age: z.coerce.number().min(18, "18歳以上である必要があります"),
password: z.string().min(8, "8文字以上で入力してください"),
});
type FormValues = z.infer<typeof formSchema>;
export const UserForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
age: 20,
password: "",
},
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} placeholder="メールアドレス" />
{errors.email && <p>{errors.email.message}</p>}
<input type="number" {...register("age")} placeholder="年齢" />
{errors.age && <p>{errors.age.message}</p>}
<input type="password" {...register("password")} placeholder="パスワード" />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">送信</button>
</form>
);
};
RHF単体のときと比べて、フォーム内部からルールが完全に消えています。
Zod側でスキーマを定義しておけば、
- 型定義 (
FormValues) - バリデーションルール
を1つの場所で管理できるようになります。
また、ここでは defaultValues を設定しています。
実務では「編集フォームで既存データを表示する」「APIから値を初期化する」といったケースが頻出するため、
defaultValues を意識的に書いておくのは重要な習慣です。
比較まとめ
| 観点 | RHF単体 | RHF + Zod |
|---|---|---|
| バリデーション定義 | フォーム内に直書き | Zodスキーマで一元管理 |
| 型定義 | 手動で別途用意 | スキーマから自動生成 (z.infer) |
| 可読性 | 項目が増えると煩雑 | スッキリ、再利用もしやすい |
| 実務での運用 | 小規模向け | 中〜大規模開発に適する |
4. 実務で感じたメリット
実際のプロジェクトにZodを導入したとき、特に便利だったのは以下の3点でした。
-
共通のインプットパーツで同じバリデーションスキーマを再利用できる
→formSchemaの設定を一つの場所に書き、それをパーツや画面ごとに使いまわせる -
チーム開発でルールの共通化ができる
→ 「メールは必須?」「パスワードは何文字から?」のような仕様を明文化できる -
型推論が正確になる
→data.emailなどを扱うときにIDEが自動で型補完してくれる
これらは、RHF単体では意外と見落としがちなポイントです。
Zodを使うと「バリデーションルール=型定義」という一貫した設計ができるのが魅力です。
5. 応用:条件付きバリデーション
Zodでは、2つ以上のフィールドの関係をチェックするような
条件付きバリデーションも簡単に書けます。
const schema = z
.object({
password: z.string().min(8),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "パスワードが一致しません",
path: ["confirm"],
});
refine は“最終チェック”のような役割で、
フォーム全体を見てルールを追加したいときに便利です。
たとえば「チェックボックスがオンのときだけ特定の入力が必須」なども書けます。
6. スキーマを分離して保守しやすく
Zodのスキーマは、フォームごとにファイルを分けておくのが実務ではおすすめです。
※ディレクトリ構成はあくまで一例 (画面ごとにschema.tsファイルを作成して管理したほうが良い場合もあります)
src/
├─ schemas/
│ ├─ userFormSchema.ts
│ └─ loginFormSchema.ts
└─ components/
└─ forms/
└─ UserForm.tsx
同じスキーマをAPIバリデーションでも使い回すことで、
「フロントとバックのルール不一致」を防げます。
Next.jsなどのフルスタック環境では、特にこの設計が効果的です。
まとめ
React Hook Form × Zod の組み合わせは、
フォーム実装を型安全かつ保守しやすくしてくれます。
- RHFの
useFormでフォームの制御を行う - Zodで型とバリデーションをまとめる
-
zodResolverで両者をつなぐ
最初は少しコードが増えるように見えますが、
長期的には「どこに何を書けばいいか」が明確になり、
後から読む人にも優しい実装になります。
Discussion