React Hook Form + Zodで型安全なフォームを実装する|バリデーション・エラー表示・送信処理の完全ガイド


はじめに

前回の記事では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 FormZod
フォームの状態管理✅ 非制御コンポーネントで最小レンダリング—
バリデーション統合✅ 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を確認する方法などを詳しくお伝えします。

コメント

タイトルとURLをコピーしました