はじめに
前回の記事ではTanStack QueryでAPIデータの取得・キャッシュ・ミューテーションを管理する方法を解説しました。今回はReact Hook FormとZodを組み合わせてバリデーション付きフォームを効率よく実装する方法を解説します。第二十四弾でフォームのバリデーションをゼロから実装しましたが、今回はライブラリを活用することでコードを大幅にシンプルにしながらより堅牢なフォームを実現します。
1. なぜReact Hook FormとZodを使うのか
1.1 useStateによるフォーム管理の課題
前回までの連載でフォームを実装するとき useState を使ってきました。
tsx
// ❌ useStateだけで管理した場合の課題
function ContactForm() {
const [name, setName] = useState( '' );
const [email, setEmail] = useState( '' );
const [message, setMessage] = useState( '' );
const [errors, setErrors] = useState<Record<string, string>>( {} );
// バリデーション関数を自分で書く必要がある
const validate = () => { /* 長くなる */ };
// フィールドごとにonChangeを書く必要がある
// レンダリングのたびにすべてのフィールドが再レンダリングされる
// ...
}
| 問題点 | 内容 |
|---|---|
| ボイラープレートが多い | フィールドごとにstateとonChangeを書く必要がある |
| 再レンダリングが多い | 1文字入力のたびにフォーム全体が再レンダリングされる |
| バリデーションロジックが煩雑 | 複雑なルールを自前で実装する必要がある |
| 型安全性が弱い | バリデーションと型定義が別々になりがち |
1.2 React Hook Form + Zodが解決すること
| 機能 | React Hook Form | Zod |
|---|---|---|
| フォームの状態管理 | ✅ 非制御コンポーネントで最小レンダリング | — |
| バリデーション統合 | ✅ zodリゾルバーで自動連携 | ✅ スキーマでルール定義 |
| 型安全性 | ✅ TypeScript対応 | ✅ スキーマから型を自動推論 |
| エラー表示 | ✅ errors オブジェクトで管理 | — |
| 送信処理 | ✅ handleSubmit でバリデーション実行 | — |
2. インストールと初期設定
bash
# React Hook FormとZod・リゾルバーをインストールする
npm install react-hook-form @hookform/resolvers zod
3. Zodでスキーマを定義する
3.1 基本的なスキーマの書き方
typescript
// src/schemas/contactSchema.ts
import { z } from 'zod';
// フォームのバリデーションスキーマを定義する
export const contactSchema = z.object( {
name: z
.string()
.min( 1, { message: 'お名前は必須です。' } )
.min( 2, { message: 'お名前は2文字以上で入力してください。' } )
.max( 50, { message: 'お名前は50文字以内で入力してください。' } ),
email: z
.string()
.min( 1, { message: 'メールアドレスは必須です。' } )
.email( { message: '正しいメールアドレスを入力してください。' } ),
phone: z
.string()
.optional() // 入力任意
.refine(
val => !val || /^[\d\-\+\(\)\s]+$/.test( val ),
{ message: '正しい電話番号を入力してください。' }
),
category: z.enum(
['general', 'support', 'business', 'other'],
{ errorMap: () => ( { message: 'お問い合わせ種別を選択してください。' } ) }
),
message: z
.string()
.min( 1, { message: 'お問い合わせ内容は必須です。' } )
.min( 10, { message: 'お問い合わせ内容は10文字以上で入力してください。' } )
.max( 1000, { message: 'お問い合わせ内容は1000文字以内で入力してください。' } ),
agree: z
.boolean()
.refine( val => val === true, {
message: 'プライバシーポリシーへの同意が必要です。',
} ),
} );
// スキーマからTypeScriptの型を自動で生成する(手動で型を書く必要がない)
export type ContactFormData = z.infer<typeof contactSchema>;
💡 z.infer<typeof schema> でスキーマから型を自動生成できます: バリデーションルールと型定義が1つのスキーマに統一されるため、「型は通るがバリデーションで弾かれる」といった不整合が起きなくなります。
3.2 複雑なバリデーションルール
typescript
// パスワード確認のように複数フィールドをまたぐバリデーション
export const registerSchema = z.object( {
username: z
.string()
.min( 3, { message: 'ユーザー名は3文字以上で入力してください。' } )
.max( 20, { message: 'ユーザー名は20文字以内で入力してください。' } )
.regex( /^[a-zA-Z0-9_]+$/, {
message: 'ユーザー名は英数字とアンダースコアのみ使えます。',
} ),
email: z
.string()
.min( 1, { message: 'メールアドレスは必須です。' } )
.email( { message: '正しいメールアドレスを入力してください。' } ),
password: z
.string()
.min( 8, { message: 'パスワードは8文字以上で入力してください。' } )
.regex( /[A-Z]/, { message: '大文字を1文字以上含めてください。' } )
.regex( /[0-9]/, { message: '数字を1文字以上含めてください。' } ),
confirmPassword: z.string(),
} ).refine(
// スキーマレベルのバリデーション(複数フィールドをまたぐ)
data => data.password === data.confirmPassword,
{
message: 'パスワードが一致しません。',
path: ['confirmPassword'], // エラーを表示するフィールドを指定する
}
);
export type RegisterFormData = z.infer<typeof registerSchema>;
4. React Hook Formの基本的な使い方
4.1 最もシンプルな実装
tsx
// src/components/ContactForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/schemas/contactSchema';
function ContactForm() {
const {
register, // フィールドを登録する
handleSubmit, // フォーム送信を処理する
formState: {
errors, // バリデーションエラー
isSubmitting, // 送信中フラグ
isSubmitSuccessful, // 送信成功フラグ
},
reset, // フォームをリセットする
watch, // フィールドの値を監視する
} = useForm<ContactFormData>( {
resolver: zodResolver( contactSchema ), // Zodスキーマと連携する
defaultValues: {
name: '',
email: '',
phone: '',
category: 'general',
message: '',
agree: false,
},
mode: 'onBlur', // フォーカスを外れたタイミングでバリデーションする
} );
const onSubmit = async ( data: ContactFormData ) => {
// dataはスキーマの型でバリデーション済みのデータ
try {
await fetch( '/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( data ),
} );
reset(); // 送信成功後にフォームをリセット
} catch ( error ) {
console.error( error );
}
};
return (
<form onSubmit={handleSubmit( onSubmit )} noValidate>
{isSubmitSuccessful && (
<div className="form-message form-message--success" role="alert">
お問い合わせを受け付けました。
</div>
)}
{/* 以降のフィールドは次のセクションで */}
</form>
);
}
💡 noValidate を <form> に付けてブラウザ標準のバリデーションを無効にしましょう: React Hook Formのバリデーションと競合しないように、ブラウザ組み込みのHTML5バリデーションを無効にします。
5. 各フィールドの実装
5.1 テキスト入力フィールド
tsx
// フィールドコンポーネントを分けて再利用する
interface FormFieldProps {
label: string;
required?: boolean;
error?: string;
helper?: string;
children: React.ReactNode;
}
function FormField( { label, required, error, helper, children }: FormFieldProps ) {
return (
<div className={`form-group ${error ? 'is-error' : ''}`}>
<label className="form-label">
{label}
{required && <span className="required" aria-label="必須">*</span>}
</label>
{children}
{error && <p className="form-error-message" role="alert">{error}</p>}
{helper && <p className="form-helper-text">{helper}</p>}
</div>
);
}
// ContactFormの中に各フィールドを実装する
function ContactForm() {
const { register, handleSubmit, formState: { errors, isSubmitting }, reset }
= useForm<ContactFormData>( {
resolver: zodResolver( contactSchema ),
mode: 'onBlur',
} );
return (
<form onSubmit={handleSubmit( onSubmit )} noValidate>
{/* お名前 */}
<FormField
label="お名前"
required
error={errors.name?.message}
>
<input
type="text"
className="form-input"
placeholder="山田 太郎"
autoComplete="name"
{...register( 'name' )}
// register()を展開するとref・name・onChange・onBlurが渡される
/>
</FormField>
{/* メールアドレス */}
<FormField
label="メールアドレス"
required
error={errors.email?.message}
helper="確認メールをお送りします。"
>
<input
type="email"
className="form-input"
placeholder="yamada@example.com"
autoComplete="email"
inputMode="email"
{...register( 'email' )}
/>
</FormField>
{/* 電話番号(任意) */}
<FormField
label="電話番号"
error={errors.phone?.message}
helper="任意項目です。"
>
<input
type="tel"
className="form-input"
placeholder="090-1234-5678"
autoComplete="tel"
inputMode="tel"
{...register( 'phone' )}
/>
</FormField>
{/* お問い合わせ種別 */}
<FormField
label="お問い合わせ種別"
required
error={errors.category?.message}
>
<select
className="form-select"
{...register( 'category' )}
>
<option value="">-- 選択してください --</option>
<option value="general">一般的なお問い合わせ</option>
<option value="support">サポート</option>
<option value="business">ビジネスのご相談</option>
<option value="other">その他</option>
</select>
</FormField>
{/* お問い合わせ内容 */}
<FormField
label="お問い合わせ内容"
required
error={errors.message?.message}
>
<textarea
className="form-textarea"
rows={6}
placeholder="お問い合わせ内容をご記入ください。"
{...register( 'message' )}
/>
</FormField>
{/* プライバシーポリシー同意 */}
<div className={`form-group form-group--checkbox ${errors.agree ? 'is-error' : ''}`}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox-input"
{...register( 'agree' )}
/>
<span>
<a href="/privacy" target="_blank">プライバシーポリシー</a>
に同意する
</span>
</label>
{errors.agree && (
<p className="form-error-message" role="alert">
{errors.agree.message}
</p>
)}
</div>
{/* 送信ボタン */}
<button
type="submit"
className={`form-submit ${isSubmitting ? 'is-loading' : ''}`}
disabled={isSubmitting}
>
{isSubmitting ? '送信中...' : '送信する'}
</button>
</form>
);
}
6. useControllerで複雑なUIに対応する
6.1 カスタムコンポーネントとの連携
register() はHTMLネイティブ要素向けですが、カスタムUIコンポーネントには useController を使います。
tsx
// src/components/forms/RatingInput.tsx
import { useController, type Control } from 'react-hook-form';
import type { ContactFormData } from '@/schemas/contactSchema';
interface RatingInputProps {
name: keyof ContactFormData;
control: Control<ContactFormData>;
label: string;
}
function RatingInput( { name, control, label }: RatingInputProps ) {
const {
field: { value, onChange },
fieldState: { error },
} = useController( { name, control } );
return (
<div className="form-group">
<p className="form-label">{label}</p>
<div className="rating-stars" role="group" aria-label={label}>
{[1, 2, 3, 4, 5].map( star => (
<button
key={star}
type="button"
onClick={() => onChange( star )}
className={`star-btn ${( value as number ) >= star ? 'is-active' : ''}`}
aria-label={`${star}点`}
aria-pressed={( value as number ) >= star}
>
★
</button>
) )}
</div>
{error && <p className="form-error-message">{error.message}</p>}
</div>
);
}
7. フォームの値の監視
7.1 watchで値をリアルタイムに取得する
tsx
function ContactForm() {
const { register, watch, /* ... */ } = useForm<ContactFormData>( {
resolver: zodResolver( contactSchema ),
} );
// 特定フィールドの値を監視する
const messageValue = watch( 'message' );
const category = watch( 'category' );
return (
<form>
{/* カテゴリーが「business」のときだけ追加フィールドを表示する */}
{category === 'business' && (
<FormField label="会社名" error={errors.company?.message}>
<input type="text" {...register( 'company' )} />
</FormField>
)}
<FormField label="お問い合わせ内容" required error={errors.message?.message}>
<textarea {...register( 'message' )} />
{/* 文字数カウンター */}
<div className={`char-counter ${
messageValue?.length >= 900 ? 'is-limit' :
messageValue?.length >= 800 ? 'is-warning' : ''
}`}>
{messageValue?.length ?? 0} / 1000
</div>
</FormField>
</form>
);
}
8. CSSでエラー状態を表現する
8.1 エラー状態のスタイル
css
/* フォームグループのエラー状態 */
.form-group.is-error .form-input,
.form-group.is-error .form-textarea,
.form-group.is-error .form-select {
border-color: var( --form-border-error, #E24B4A );
background: #FFF5F5;
}
/* エラーメッセージ */
.form-error-message {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 1.3rem;
color: #E24B4A;
margin-top: 0.4rem;
}
.form-error-message::before {
content: '⚠';
font-size: 1.2rem;
}
/* フォーカス時のフォーカスリング */
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: var( --color-primary, #1A5FC0 );
box-shadow: 0 0 0 3px rgba( 26, 95, 192, 0.15 );
}
/* エラー状態のフォーカスリング */
.form-group.is-error .form-input:focus,
.form-group.is-error .form-textarea:focus {
box-shadow: 0 0 0 3px rgba( 226, 75, 74, 0.15 );
}
9. 完成したフォームの全体構成
src/
├── schemas/
│ ├── contactSchema.ts ← Zodスキーマと型定義
│ └── registerSchema.ts
├── components/
│ ├── forms/
│ │ ├── FormField.tsx ← ラベル・エラー表示の共通コンポーネント
│ │ ├── ContactForm.tsx ← お問い合わせフォーム
│ │ └── RegisterForm.tsx
│ └── ui/
│ └── Button.tsx
└── hooks/
└── mutations/
└── useContactMutation.ts ← TanStack QueryのuseMutationと連携
9.1 TanStack Queryと連携する
tsx
// src/hooks/mutations/useContactMutation.ts
import { useMutation } from '@tanstack/react-query';
import type { ContactFormData } from '@/schemas/contactSchema';
export function useContactMutation() {
return useMutation( {
mutationFn: async ( data: ContactFormData ) => {
const res = await fetch( '/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( data ),
} );
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json();
},
} );
}
// ContactForm.tsx でuseMutationと連携する
function ContactForm() {
const contactMutation = useContactMutation();
const { register, handleSubmit, formState: { errors }, reset }
= useForm<ContactFormData>( { resolver: zodResolver( contactSchema ) } );
const onSubmit = ( data: ContactFormData ) => {
contactMutation.mutate( data, {
onSuccess: () => reset(),
} );
};
return (
<form onSubmit={handleSubmit( onSubmit )}>
{contactMutation.isError && (
<div className="form-message form-message--error" role="alert">
送信に失敗しました。再度お試しください。
</div>
)}
{contactMutation.isSuccess && (
<div className="form-message form-message--success" role="alert">
お問い合わせを受け付けました。
</div>
)}
{/* フィールド省略 */}
<button
type="submit"
disabled={contactMutation.isPending}
>
{contactMutation.isPending ? '送信中...' : '送信する'}
</button>
</form>
);
}
10. 実践チェックリスト
✓ npm install react-hook-form @hookform/resolvers zod でインストールできたか
✓ Zodスキーマで全フィールドのバリデーションルールを定義したか
✓ z.infer<typeof schema> でフォームの型を自動生成しているか
✓ zodResolver を useForm の resolver に渡しているか
✓ mode: 'onBlur' でフォーカスを外れたタイミングでバリデーションしているか
✓ <form> タグに noValidate を付けてブラウザのバリデーションを無効にしているか
✓ 各フィールドに {...register('fieldName')} を渡しているか
✓ errors.fieldName?.message でエラーメッセージを表示しているか
✓ エラーメッセージに role="alert" を付けてアクセシビリティに対応しているか
✓ 送信成功後に reset() でフォームをクリアしているか
まとめ
今回はReact Hook FormとZodを組み合わせてバリデーション付きフォームを実装する方法を解説しました。ポイントをまとめると:
- React Hook Formは非制御コンポーネントで動作して
useStateに比べて再レンダリングが最小限になる - Zodでスキーマを定義して
z.inferでTypeScriptの型を自動生成するとバリデーションと型が一致する zodResolverを使うとZodスキーマをReact Hook Formのバリデーターとして使えるregister()で各フィールドを登録してerrors.fieldName?.messageでエラーを表示するmode: 'onBlur'にするとフォーカスを外れたタイミングでバリデーションが走り体験が自然になる- パスワード確認のような複数フィールドをまたぐバリデーションはZodの
.refine()で実装する watch()でフィールドの値を監視して条件付き表示などに活用できる- TanStack Queryの
useMutationと組み合わせると送信処理・エラー表示・成功後の処理が整理される
次の記事では、Googleの検証ツールの使い方について解説します。Elementの名称の上にマウスホバーすると何ができるか、Styleのタブで適用されているstyleを確認する方法などを詳しくお伝えします。
コメント