React Hook Form + Zod
このコースのメインゴール!フォームライブラリ React Hook Form と バリデーションライブラリ Zod を使って、チャプター8で苦労した登録フォームを劇的に短く・安全に書き直します。Reactの真価はそのエコシステムにあることを体感しましょう。
このチャプターで学ぶこと
- なぜフォームライブラリが必要なのかを説明できる
- Zodでバリデーションスキーマを定義できる
- useFormフックの基本(register, handleSubmit, formState)を使いこなせる
- zodResolverでZodとReact Hook Formをつなぎこめる
- チャプター8のフォームをRHF+Zodで書き直せる
- ライブラリを活用してコードを劇的に削減できる
React Hook Form + Zod
ついにここまで来ました。 このチャプターがこのコース全体の目的地です。
チャプター8で、フォームを手作りすることの苦労を体験しました。useState の乱立、コピー&ペーストのハンドラー、長いバリデーション関数……。あの苦労は無駄ではありませんでした。今から、その苦労を一気に解消する方法を見せます。
なぜライブラリが必要なのか
チャプター8で作ったコードを振り返ります:
// チャプター8: 9つのuseState、手動バリデーション40行以上、コピペのonChange...
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
const [isSubmitted, setIsSubmitted] = useState(false);
// ... さらにバリデーション関数40行以上 ...
フォームを作るたびにこれを繰り返すのでしょうか?いいえ。Reactの世界には、この問題を解決するために作られたライブラリが存在します。
React Hook Form はフォームの状態管理と送信を担当し、Zod はバリデーションルールの定義を担当します。この2つを組み合わせることで、チャプター8のコードの8割以上を削除できます。
ReactはUIを構築するためのライブラリであり、フォーム、ルーティング、状態管理などは意図的にコアに含まれていません。代わりに、世界中の開発者が作った高品質なライブラリ群(エコシステム)が存在します。
「必要な問題に特化したライブラリを組み合わせる」——これがReact流の問題解決スタイルです。今日はそのパワーを最も体感しやすいフォームで経験します。
インストール
まずは3つのパッケージをインストールします:
npm install react-hook-form zod @hookform/resolvers
- react-hook-form: フォーム状態管理とバリデーションのフレームワーク
- zod: TypeScriptファーストのスキーマバリデーションライブラリ
- @hookform/resolvers: ZodをReact Hook Formに橋渡しするアダプター
Zodとは何か
React Hook Formを学ぶ前に、Zodについて理解しましょう。
Zodはスキーマ定義とバリデーションのためのライブラリです。「このデータはこういう形であるべき」というルールをコードで表現できます。
LaravelにはForm Requestという仕組みがあります。コントローラーの外にバリデーションルールをまとめて定義できる仕組みです:
// Laravel: FormRequestでルールを定義
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email'],
'password' => ['required', 'min:8', 'confirmed'],
];
}Zodはまさにこれと同じ考え方です。バリデーションロジックをコンポーネントの外に「スキーマ」として定義します。
Zodのスキーマ定義
import { z } from 'zod';
// 登録フォームのスキーマを定義する
const registrationSchema = z.object({
name: z
.string()
.min(1, { message: '名前を入力してください' }),
email: z
.string()
.min(1, { message: 'メールアドレスを入力してください' })
.email({ message: '有効なメールアドレスを入力してください' }),
password: z
.string()
.min(1, { message: 'パスワードを入力してください' })
.min(8, { message: 'パスワードは8文字以上にしてください' }),
confirmPassword: z
.string()
.min(1, { message: 'パスワード(確認)を入力してください' }),
}).refine(
// パスワードの一致チェックはrefineで追加する
(data) => data.password === data.confirmPassword,
{
message: 'パスワードが一致しません',
path: ['confirmPassword'], // エラーをconfirmPasswordフィールドに紐付ける
}
);
これだけです。 チャプター8で手動で書いていたバリデーションロジック全体が、このスキーマ定義に集約されました。しかも:
- エラーメッセージが日本語で定義できる
- ルールが宣言的で読みやすい
- ロジックではなく「仕様」として表現されている
TypeScriptとの連携(おまけ)
Zodのもう一つの強みは、スキーマから型を自動生成できることです:
// スキーマから型を自動生成する
type RegistrationFormData = z.infer<typeof registrationSchema>;
// 結果: { name: string; email: string; password: string; confirmPassword: string; }
これにより「バリデーションルール」と「TypeScriptの型」が常に一致します。
React Hook Form の基本
次に、React Hook Formの主役3つを学びましょう。
useForm フック
import { useForm } from 'react-hook-form';
function MyForm() {
const {
register, // inputをRHFに登録する関数
handleSubmit, // 送信処理をラップする関数
formState, // フォームの状態(エラーなど)を持つオブジェクト
} = useForm();
// ...
}
たった1つの useForm() を呼ぶだけです。チャプター8の9つの useState が、これ1行に置き換わります。
register: inputをRHFに登録する
// チャプター8の書き方
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
// React Hook Formの書き方
<input
type="text"
{...register('name')}
/>
register('name') はオブジェクトを返し、スプレッド構文で <input> に展開します。value、onChange、ref などを自動でセットしてくれます。onChange を自分で書く必要はもうありません。
handleSubmit: 送信処理のラッパー
// チャプター8の書き方
function handleSubmit(e) {
e.preventDefault();
const isValid = validate();
if (isValid) {
// 送信処理
}
}
// React Hook Formの書き方
function onSubmit(data) {
// dataには自動的にバリデーション済みの値が入っている
console.log(data);
}
// JSX
<form onSubmit={handleSubmit(onSubmit)}>
handleSubmit(onSubmit) がバリデーション実行と e.preventDefault() を自動で行います。バリデーションが通った場合だけ onSubmit が呼ばれ、引数 data にフォームの値が渡されます。
formState.errors: エラー情報へのアクセス
const { formState: { errors } } = useForm();
// 各フィールドのエラーメッセージにアクセス
{errors.name && <p>{errors.name.message}</p>}
{errors.email && <p>{errors.email.message}</p>}
エラーのStateを自分で管理する必要はありません。errors オブジェクトに全部まとまっています。
ZodとReact Hook Formをつなぐ
@hookform/resolvers の zodResolver を使うと、Zodで定義したスキーマをRHFのバリデーターとして使えます:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// スキーマを先に定義する
const registrationSchema = z.object({
// ... バリデーションルール
});
function RegistrationForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
// resolverにzodResolverを渡すだけでつながる
resolver: zodResolver(registrationSchema),
});
// これだけ!
}
たった1行 resolver: zodResolver(registrationSchema) を追加するだけで、RHFとZodが連携します。
完全リファクタリング: Before & After
いよいよ本番です。チャプター8のフォームを、RHF + Zodで書き直します。コードを並べて比較してみましょう。
Before(チャプター8のコード)
コードの長さと複雑さを確認してください:
// チャプター8: 約90行のコード
import { useState } from 'react';
function RegistrationForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [nameError, setNameError] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
const [isSubmitted, setIsSubmitted] = useState(false);
function validate() {
let isValid = true;
if (!name.trim()) {
setNameError('名前を入力してください');
isValid = false;
} else { setNameError(''); }
if (!email.trim()) {
setEmailError('メールアドレスを入力してください');
isValid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('有効なメールアドレスを入力してください');
isValid = false;
} else { setEmailError(''); }
if (!password) {
setPasswordError('パスワードを入力してください');
isValid = false;
} else if (password.length < 8) {
setPasswordError('パスワードは8文字以上にしてください');
isValid = false;
} else { setPasswordError(''); }
if (!confirmPassword) {
setConfirmPasswordError('パスワード(確認)を入力してください');
isValid = false;
} else if (password !== confirmPassword) {
setConfirmPasswordError('パスワードが一致しません');
isValid = false;
} else { setConfirmPasswordError(''); }
return isValid;
}
function handleSubmit(e) {
e.preventDefault();
const isValid = validate();
if (isValid) {
console.log('送信データ:', { name, email, password });
setIsSubmitted(true);
}
}
if (isSubmitted) {
return <p>登録が完了しました!ようこそ、{name}さん!</p>;
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>名前</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} />
{nameError && <p style={{ color: 'red' }}>{nameError}</p>}
</div>
<div>
<label>メールアドレス</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
{emailError && <p style={{ color: 'red' }}>{emailError}</p>}
</div>
<div>
<label>パスワード</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{passwordError && <p style={{ color: 'red' }}>{passwordError}</p>}
</div>
<div>
<label>パスワード(確認)</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
{confirmPasswordError && <p style={{ color: 'red' }}>{confirmPasswordError}</p>}
</div>
<button type="submit">登録する</button>
</form>
);
}
After(React Hook Form + Zod)
同じフォームです。心の準備はいいですか:
// チャプター9: 約50行のコード(しかもよりパワフル)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useState } from 'react';
// バリデーションルールをスキーマとして宣言する
const registrationSchema = z.object({
name: z.string().min(1, { message: '名前を入力してください' }),
email: z
.string()
.min(1, { message: 'メールアドレスを入力してください' })
.email({ message: '有効なメールアドレスを入力してください' }),
password: z
.string()
.min(1, { message: 'パスワードを入力してください' })
.min(8, { message: 'パスワードは8文字以上にしてください' }),
confirmPassword: z.string().min(1, { message: 'パスワード(確認)を入力してください' }),
}).refine((data) => data.password === data.confirmPassword, {
message: 'パスワードが一致しません',
path: ['confirmPassword'],
});
function RegistrationForm() {
const [isSubmitted, setIsSubmitted] = useState(false);
const [submittedName, setSubmittedName] = useState('');
// useFormにスキーマを渡すだけで準備完了
const {
register,
handleSubmit,
formState: { errors },
} = useForm({ resolver: zodResolver(registrationSchema) });
// バリデーション済みのdataが直接渡ってくる
function onSubmit(data) {
console.log('送信データ:', data);
setSubmittedName(data.name);
setIsSubmitted(true);
}
if (isSubmitted) {
return <p>登録が完了しました!ようこそ、{submittedName}さん!</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>名前</label>
<input type="text" {...register('name')} />
{errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
</div>
<div>
<label>メールアドレス</label>
<input type="email" {...register('email')} />
{errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
</div>
<div>
<label>パスワード</label>
<input type="password" {...register('password')} />
{errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
</div>
<div>
<label>パスワード(確認)</label>
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>}
</div>
<button type="submit">登録する</button>
</form>
);
}
export default RegistrationForm;
変化の対比まとめ
| 項目 | チャプター8(手動) | チャプター9(RHF + Zod) |
|---|---|---|
| useState の数 | 9個 | 2個(送信成功フラグのみ) |
| onChange ハンドラー | 4行のコピー&ペースト | 不要 |
| バリデーションロジック | 40行超の命令的コード | 宣言的スキーマ1つ |
| エラーメッセージの管理 | 4つのState + setError呼び出し | errors オブジェクトに自動 |
| フィールド追加のコスト | State + onChange + バリデーション条件 + エラーState を全部追加 | スキーマに1行 + JSXに1フィールド |
| コード行数(目安) | 約90行 | 約50行 |
チャプター8のフォームに新しいフィールド(例: 電話番号)を追加するとしたら、何カ所を変更する必要があるか数えてみてください。次に、チャプター9のコードで同じことをしたら何カ所か比べてみましょう。スキーマに1行、JSXに1フィールドのブロックを追加するだけです。
RHFの追加機能: isSubmitting と isValid
React Hook Formには、UIをよりよくするための状態も用意されています:
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm({
resolver: zodResolver(registrationSchema),
mode: 'onChange', // 入力のたびにバリデーションを実行する(デフォルトはonSubmit)
});
// isSubmittingは送信中にtrueになる(APIリクエスト中にボタンを無効化するなど)
// isValidはフォーム全体が有効かどうか
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '送信中...' : '登録する'}
</button>
mode: 'onChange' を設定すると、入力のたびにリアルタイムでバリデーションが実行されます。ユーザーが入力しながらエラーを修正できる、より良いUXになります。
Zodのバリデーションルール早見表
よく使うZodのバリデーションメソッドをまとめます:
import { z } from 'zod';
z.object({
// 文字列のバリデーション
name: z.string()
.min(1, { message: '必須項目です' }) // 最小文字数
.max(50, { message: '50文字以内で入力してください' }) // 最大文字数
.email({ message: 'メール形式で入力してください' }) // メール形式
.url({ message: 'URL形式で入力してください' }), // URL形式
// 数値のバリデーション
age: z.number()
.min(18, { message: '18歳以上である必要があります' })
.max(120, { message: '有効な年齢を入力してください' }),
// 任意項目(undefined/nullを許容する)
bio: z.string().optional(),
// 選択肢から一つを選ぶ
prefecture: z.enum(
['北海道', '青森県', '岩手県', /* ... */],
{ errorMap: () => ({ message: '都道府県を選択してください' }) }
),
});
LaravelのバリデーションルールとZodを並べると、思想がそっくりです:
// Laravel
'name' => ['required', 'string', 'max:255'],
'age' => ['required', 'integer', 'min:18'],
'email' => ['required', 'email'],// Zod
name: z.string().min(1).max(255),
age: z.number().min(18),
email: z.string().email(),「バリデーションルールをデータと一緒に定義する」という考え方は同じです。違いは、ZodはJavaScriptの型システムと統合されているため、バリデーションと型定義が同時に得られることです。
フォームのデフォルト値を設定する
初期値を設定したい場合は defaultValues オプションを使います:
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(registrationSchema),
defaultValues: {
name: '',
email: '',
password: '',
confirmPassword: '',
},
});
これは編集フォームで特に役立ちます。APIから取得したユーザーデータを初期値として渡せます:
// ユーザー編集フォームの例
const { register } = useForm({
resolver: zodResolver(editUserSchema),
defaultValues: {
name: user.name, // 既存のデータで初期化する
email: user.email,
},
});
React Hook Form が採用している仕組み: なぜパフォーマンスが良いのか
React Hook Formがチャプター8の手動実装より優れている理由の一つは、再レンダリングの回数です。
チャプター8では、onChange のたびに setState が呼ばれ、コンポーネント全体が再レンダリングされていました。4つのフィールドに次々と入力すれば、入力のたびに再レンダリングが走ります。
React Hook Formは内部でDOMへの直接参照(ref)を使うため、入力中はStateを更新せず、コンポーネントを再レンダリングしません。送信時や必要なタイミングでのみReactの状態を更新するため、大きなフォームでもサクサク動きます。
ほとんどのフォームライブラリは「制御コンポーネント」方式(チャプター8と同じアプローチ)を採用しています。React Hook Formが業界で最も人気を集めた理由の一つは、「非制御コンポーネント」を使うことで再レンダリングを最小限に抑えたアーキテクチャです。
演習: 追加フィールドでパワーを確かめる
チャプター9のフォームに、以下の2つのフィールドを追加してみましょう。RHF + Zodのコードの変更がいかに少ないかを体感することが目標です。
追加するフィールド
1. 年齢(age)
- 数値入力(
type="number") - 必須
- 18歳以上、120歳以下
- エラーメッセージ: 「18歳以上の方のみ登録できます」
2. 都道府県(prefecture)
- セレクトボックス(
<select>) - 必須(「選択してください」という空のオプションを含む)
- エラーメッセージ: 「都道府県を選択してください」
Zodでの数値バリデーション注意点
<input type="number"> はHTMLのフォームの値として文字列を返すため、Zodの z.number() を使うには変換が必要です:
// coerce(強制変換)を使うと文字列を数値に自動変換してくれる
age: z.coerce.number()
.min(18, { message: '18歳以上の方のみ登録できます' })
.max(120, { message: '有効な年齢を入力してください' }),セレクトボックスの登録
register はセレクトボックスにも使えます:
<select {...register('prefecture')}>
<option value="">-- 選択してください --</option>
<option value="北海道">北海道</option>
<option value="東京都">東京都</option>
{/* ... */}
</select>
{errors.prefecture && <p style={{ color: 'red' }}>{errors.prefecture.message}</p>}チェックリスト
- スキーマに
ageとprefectureのルールを追加した - JSXに新しいフィールドのブロックを追加した
- 年齢に18未満の数値を入力するとエラーが表示される
- 都道府県を選択せずに送信するとエラーが表示される
- 正しく入力して送信すると
dataオブジェクトにageとprefectureが含まれている
振り返り
追加に必要だった変更は何カ所でしたか?チャプター8のフォームに同じフィールドを追加するとしたら何カ所の変更が必要か、比べてみてください。
まとめ: ライブラリという選択
このチャプターで学んだことを振り返りましょう。
Zodで学んだこと:
- バリデーションルールを宣言的なスキーマとして定義する方法
- カスタムエラーメッセージの設定
refineを使ったフィールド間の関連バリデーション- TypeScriptの型をスキーマから自動生成する方法
React Hook Formで学んだこと:
useFormでuseStateの乱立を排除する方法registerでonChangeハンドラーを撤廃する方法handleSubmitでバリデーションと送信処理を分離する方法formState.errorsでエラーを宣言的に扱う方法
そして最も大切なこと:
Reactの真の力は、コア機能そのものよりも、世界中の開発者が作り上げたエコシステムにある。
フォームの問題にはReact Hook Form + Zod。ルーティングの問題にはReact Router。データ取得の問題にはTanStack Query。「問題を認識し、その問題を解決するために作られたライブラリを選ぶ」——このパターンがReact開発のコアスキルです。
次のチャプターでは、このエコシステム全体を俯瞰します。React開発の「地図」を手に入れて、このコースを締めくくりましょう。