React Hook Form+Zodでバリデーション|型安全なフォーム実装

React Hook FormとZodを組み合わせた型安全なフォームバリデーションの実装方法を解説。スキーマ定義から実践的な使い方まで初心者向けに詳しく紹介します。

Learning Next 運営
43 分で読めます

あなたも、Reactでフォームを作る時にこんな経験はありませんか?

「バリデーションが面倒...」「型安全性が保てない...」「エラーハンドリングが複雑すぎる...」

フォーム開発で、バリデーション処理は避けて通れない重要な要素ですよね。 でも、従来の方法では実装が複雑になりがちで、型安全性を保つのも困難でした。

でも大丈夫です!

この記事では、React Hook FormとZodを組み合わせた型安全なフォームバリデーションの実装方法を詳しく解説します。 スキーマ定義から実践的な使い方まで、コード例と一緒に学んでいきましょう。

React Hook FormとZodって何?まずは基本を理解しよう

まずは、React Hook FormとZodの特徴を理解しましょう。

「なんでこの2つを使うの?」という疑問も、これを見ればスッキリしますよ。

React Hook Formはフォームのスーパーヒーロー

React Hook Formは、パフォーマンスと使いやすさを重視したフォームライブラリです。

基本的な使い方を見てみよう

import { useForm } from 'react-hook-form';

// React Hook Formの基本的な使い方
function SimpleForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();
  
  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input 
        {...register('email', { required: 'メールアドレスは必須です' })}
        placeholder="メールアドレス"
      />
      {errors.email && <p>{errors.email.message}</p>}
      
      <button type="submit">送信</button>
    </form>
  );
}

このコードでは、useFormフックでフォームの機能を取得しています。

register関数で入力フィールドを登録し、handleSubmitで送信処理を行います。 errorsからバリデーションエラーを取得できるんです。

React Hook Formの主なメリット

React Hook Formは、以下の利点があります。

  • 最小限の再レンダリング: 入力値の変更時に無駄な再レンダリングを防ぐ
  • 簡潔なAPI: 複雑な設定なしで使い始められる
  • TypeScriptサポート: 型安全性を保ちながら開発できる

作業がとてもサクサク進むので、開発が楽しくなりますよ。

Zodでスキーマを定義しよう

Zodは、TypeScriptファーストなスキーマ検証ライブラリです。

Zodの基本的な使い方

import { z } from 'zod';

// スキーマの定義
const UserSchema = z.object({
  name: z.string().min(2, '名前は2文字以上で入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  age: z.number().min(0, '年齢は0以上で入力してください'),
});

// 型の生成
type User = z.infer<typeof UserSchema>;

このコードでは、ユーザーのデータ構造を定義しています。

z.objectでオブジェクトの形を定義し、各フィールドにバリデーションルールを設定しています。 z.inferで、スキーマからTypeScriptの型を自動生成できるんです。

バリデーションの実行例

// バリデーションの実行
const validateUser = (data) => {
  try {
    const result = UserSchema.parse(data);
    return { success: true, data: result };
  } catch (error) {
    return { success: false, errors: error.errors };
  }
};

UserSchema.parse()でデータを検証し、成功すれば検証済みデータを返します。 失敗した場合はエラー情報を取得できます。

Zodを使うことで、型定義とバリデーションルールを一箷所で管理できるんです。

いよいよ組み合わせ!React Hook FormとZodの統合

React Hook FormとZodを組み合わせると、すごいパワーを発揮します。

「どうやって連携させるの?」という疑問を、一緒に解決していきましょう。

基本的な統合方法

React Hook FormとZodを組み合わせるには、@hookform/resolversパッケージを使います。

必要なパッケージをインストールしよう

npm install react-hook-form zod @hookform/resolvers

この3つのパッケージがあれば、統合の準備は完了です。

統合の基本例を見てみよう

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// スキーマの定義
const formSchema = z.object({
  username: z.string().min(3, 'ユーザー名は3文字以上で入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上で入力してください'),
});

// 型の生成
type FormData = z.infer<typeof formSchema>;

function RegisterForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(formSchema),
  });

  const onSubmit = async (data) => {
    try {
      console.log('登録データ:', data);
      await new Promise(resolve => setTimeout(resolve, 1000));
      alert('登録が完了しました');
    } catch (error) {
      console.error('登録エラー:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">ユーザー名</label>
        <input
          id="username"
          {...register('username')}
          placeholder="ユーザー名を入力"
        />
        {errors.username && (
          <span style={{ color: 'red' }}>{errors.username.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          placeholder="メールアドレスを入力"
        />
        {errors.email && (
          <span style={{ color: 'red' }}>{errors.email.message}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">パスワード</label>
        <input
          id="password"
          type="password"
          {...register('password')}
          placeholder="パスワードを入力"
        />
        {errors.password && (
          <span style={{ color: 'red' }}>{errors.password.message}</span>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '送信中...' : '登録'}
      </button>
    </form>
  );
}

このコードでは、zodResolver(formSchema)でReact Hook FormとZodを連携させています。

スキーマで定義したバリデーションルールが、自動的にReact Hook Formに適用されます。 タイプセーフなフォームが、こんなに簡単に作れるんです。

より高度なバリデーションを作ってみよう

もっと複雑なバリデーションルールを実装してみましょう。

import { z } from 'zod';

// 複雑なスキーマ定義
const advancedSchema = z.object({
  // 基本的なバリデーション
  firstName: z.string().min(1, '名前は必須です').max(50, '名前は50文字以内で入力してください'),
  lastName: z.string().min(1, '姓は必須です').max(50, '姓は50文字以内で入力してください'),
  
  // メールアドレスの複合バリデーション
  email: z.string()
    .email('有効なメールアドレスを入力してください')
    .refine(email => !email.includes('+'), {
      message: 'エイリアスメールアドレスは使用できません'
    }),
  
  // パスワードの複合バリデーション
  password: z.string()
    .min(8, 'パスワードは8文字以上で入力してください')
    .regex(/[A-Z]/, 'パスワードには大文字を含めてください')
    .regex(/[a-z]/, 'パスワードには小文字を含めてください')
    .regex(/[0-9]/, 'パスワードには数字を含めてください')
    .regex(/[^A-Za-z0-9]/, 'パスワードには記号を含めてください'),
  
  // パスワード確認
  confirmPassword: z.string(),
  
  // 年齢の範囲指定
  age: z.number()
    .min(18, '18歳以上である必要があります')
    .max(120, '有効な年齢を入力してください'),
  
  // 選択項目
  gender: z.enum(['male', 'female', 'other'], {
    errorMap: () => ({ message: '性別を選択してください' })
  }),
  
  // 配列のバリデーション
  hobbies: z.array(z.string()).min(1, '趣味を少なくとも1つ選択してください'),
  
  // 条件付きバリデーション
  hasJob: z.boolean(),
  jobTitle: z.string().optional(),
  
  // 利用規約への同意
  agreeToTerms: z.boolean().refine(val => val === true, {
    message: '利用規約に同意してください'
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"],
}).refine((data) => {
  // 仕事がある場合は職種が必須
  if (data.hasJob) {
    return data.jobTitle && data.jobTitle.trim().length > 0;
  }
  return true;
}, {
  message: "職種を入力してください",
  path: ["jobTitle"],
});

type AdvancedFormData = z.infer<typeof advancedSchema>;

この例では、多様なバリデーションルールを組み合わせています。

パスワードの強度チェック、条件付きバリデーション、複数フィールドの関連チェックなど、実用的なバリデーションが実装できています。

実際に使える!実践的なフォームを作ってみよう

実際のアプリケーションで使える、完全なユーザー登録フォームを作成してみましょう。

「どんな風に作るの?」という疑問を、コードで解決していきますね。

ユーザー登録フォームの完全実装

まずは、全体の概要を見てみましょう。

import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

// スキーマ定義
const registrationSchema = z.object({
  username: z.string()
    .min(3, 'ユーザー名は3文字以上で入力してください')
    .max(20, 'ユーザー名は20文字以内で入力してください')
    .regex(/^[a-zA-Z0-9_]+$/, 'ユーザー名は英数字とアンダースコアのみ使用可能です'),
  
  email: z.string()
    .email('有効なメールアドレスを入力してください')
    .toLowerCase(),
  
  password: z.string()
    .min(8, 'パスワードは8文字以上で入力してください')
    .regex(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, 'パスワードには大文字、小文字、数字を含めてください'),
  
  confirmPassword: z.string(),
  
  profile: z.object({
    displayName: z.string().min(1, '表示名は必須です'),
    bio: z.string().max(200, '自己紹介は200文字以内で入力してください').optional(),
    birthDate: z.string().min(1, '生年月日を選択してください'),
  }),
  
  preferences: z.object({
    newsletter: z.boolean(),
    notifications: z.boolean(),
  }),
  
  agreeToTerms: z.boolean().refine(val => val === true, {
    message: '利用規約に同意してください'
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"],
});

type RegistrationFormData = z.infer<typeof registrationSchema>;

このスキーマでは、ユーザー登録に必要なすべての情報を定義しています。

ユーザー名は英数字とアンダースコアのみ、メールアドレスは自動的に小文字化、パスワードは強度チェック付きです。 プロフィール情報や設定も、ネストしたオブジェクトで管理しています。

フォームコンポーネントの実装

次に、実際のフォームコンポーネントを作ってみましょう。

function RegistrationForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [submitMessage, setSubmitMessage] = useState('');

  const {
    register,
    handleSubmit,
    formState: { errors },
    watch,
    reset,
  } = useForm({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      preferences: {
        newsletter: true,
        notifications: true,
      },
      agreeToTerms: false,
    },
  });

  // パスワードの一致確認のためのwatch
  const password = watch('password');

  const onSubmit = async (data) => {
    setIsSubmitting(true);
    setSubmitMessage('');

    try {
      // API呼び出しのシミュレーション
      await new Promise(resolve => setTimeout(resolve, 2000));
      
      // 実際のAPI呼び出し
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(data),
      });

      if (response.ok) {
        setSubmitMessage('登録が完了しました!確認メールをお送りしました。');
        reset();
      } else {
        const errorData = await response.json();
        setSubmitMessage(`登録エラー: ${errorData.message}`);
      }
    } catch (error) {
      setSubmitMessage('ネットワークエラーが発生しました。しばらくしてから再度お試しください。');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
      <h2>ユーザー登録</h2>
      
      {submitMessage && (
        <div style={{ 
          padding: '10px', 
          marginBottom: '20px',
          backgroundColor: submitMessage.includes('エラー') ? '#ffebee' : '#e8f5e8',
          color: submitMessage.includes('エラー') ? '#c62828' : '#2e7d32',
          borderRadius: '4px'
        }}>
          {submitMessage}
        </div>
      )}

      <form onSubmit={handleSubmit(onSubmit)}>
        {/* 基本情報 */}
        <fieldset>
          <legend>基本情報</legend>
          
          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="username">ユーザー名 *</label>
            <input
              id="username"
              {...register('username')}
              placeholder="ユーザー名(英数字とアンダースコア)"
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.username && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.username.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="email">メールアドレス *</label>
            <input
              id="email"
              type="email"
              {...register('email')}
              placeholder="メールアドレス"
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.email && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.email.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="password">パスワード *</label>
            <input
              id="password"
              type="password"
              {...register('password')}
              placeholder="パスワード(8文字以上)"
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.password && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.password.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="confirmPassword">パスワード確認 *</label>
            <input
              id="confirmPassword"
              type="password"
              {...register('confirmPassword')}
              placeholder="パスワード確認"
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.confirmPassword && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.confirmPassword.message}
              </span>
            )}
          </div>
        </fieldset>

        {/* プロフィール情報 */}
        <fieldset>
          <legend>プロフィール情報</legend>
          
          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="displayName">表示名 *</label>
            <input
              id="displayName"
              {...register('profile.displayName')}
              placeholder="表示名"
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.profile?.displayName && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.profile.displayName.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="bio">自己紹介</label>
            <textarea
              id="bio"
              {...register('profile.bio')}
              placeholder="自己紹介(200文字以内)"
              rows={4}
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.profile?.bio && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.profile.bio.message}
              </span>
            )}
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label htmlFor="birthDate">生年月日 *</label>
            <input
              id="birthDate"
              type="date"
              {...register('profile.birthDate')}
              style={{ width: '100%', padding: '8px', marginTop: '5px' }}
            />
            {errors.profile?.birthDate && (
              <span style={{ color: 'red', fontSize: '14px' }}>
                {errors.profile.birthDate.message}
              </span>
            )}
          </div>
        </fieldset>

        {/* 設定 */}
        <fieldset>
          <legend>通知設定</legend>
          
          <div style={{ marginBottom: '10px' }}>
            <label>
              <input
                type="checkbox"
                {...register('preferences.newsletter')}
                style={{ marginRight: '8px' }}
              />
              ニュースレターを受信する
            </label>
          </div>

          <div style={{ marginBottom: '15px' }}>
            <label>
              <input
                type="checkbox"
                {...register('preferences.notifications')}
                style={{ marginRight: '8px' }}
              />
              システム通知を受信する
            </label>
          </div>
        </fieldset>

        {/* 利用規約 */}
        <div style={{ marginBottom: '20px' }}>
          <label>
            <input
              type="checkbox"
              {...register('agreeToTerms')}
              style={{ marginRight: '8px' }}
            />
            利用規約に同意する *
          </label>
          {errors.agreeToTerms && (
            <div style={{ color: 'red', fontSize: '14px', marginTop: '5px' }}>
              {errors.agreeToTerms.message}
            </div>
          )}
        </div>

        <button
          type="submit"
          disabled={isSubmitting}
          style={{
            width: '100%',
            padding: '12px',
            backgroundColor: isSubmitting ? '#ccc' : '#007bff',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            fontSize: '16px',
            cursor: isSubmitting ? 'not-allowed' : 'pointer',
          }}
        >
          {isSubmitting ? '登録中...' : '登録'}
        </button>
      </form>
    </div>
  );
}

export default RegistrationForm;

この実装では、複数のセクションに分けて整理されたフォームを作成しています。

スキーマで定義したバリデーションが自動的に適用され、エラーメッセージも適切に表示されます。 ネストしたオブジェクトもregister('profile.displayName')のように簡単に取り扱えるんです。

より良いUXを実現!エラーハンドリングと改善テクニック

ユーザーにとって使いやすいフォームを作るための工夫をしてみましょう。

「エラーメッセージがわかりにくい...」こんな悩みを解決していきます。

カスタムエラーメッセージでユーザーに優しく

より分かりやすいエラーメッセージを提供するための工夫をしてみましょう。

import { z } from 'zod';

// カスタムエラーメッセージ付きスキーマ
const customErrorSchema = z.object({
  email: z.string({
    required_error: "メールアドレスは必須項目です",
    invalid_type_error: "有効なメールアドレスを入力してください"
  }).email("正しいメールアドレス形式で入力してください"),
  
  password: z.string({
    required_error: "パスワードは必須項目です"
  }).min(8, {
    message: "セキュリティのため、パスワードは8文字以上で設定してください"
  }).regex(/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
    message: "パスワードには大文字・小文字・数字をそれぞれ1文字以上含めてください"
  }),
  
  age: z.number({
    required_error: "年齢は必須項目です",
    invalid_type_error: "年齢は数値で入力してください"
  }).min(0, {
    message: "年齢は0以上の数値で入力してください"
  }).max(150, {
    message: "有効な年齢を入力してください"
  }),
});

このように、エラーメッセージを詳細にカスタマイズできます。

required_errorで必須項目のエラー、invalid_type_errorで型不一致のエラーを個別に設定できるんです。 ユーザーにとって、「何が問題なのか」がわかりやすくなりますよ。

リアルタイムバリデーションで体験向上

入力中にリアルタイムでバリデーションを実行する方法を見てみましょう。

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useEffect, useState } from 'react';

const realtimeSchema = z.object({
  username: z.string().min(3).max(20),
  email: z.string().email(),
});

function RealtimeValidationForm() {
  const [usernameAvailable, setUsernameAvailable] = useState(null);
  const [checkingUsername, setCheckingUsername] = useState(false);

  const {
    register,
    handleSubmit,
    watch,
    formState: { errors },
    trigger,
  } = useForm({
    resolver: zodResolver(realtimeSchema),
    mode: 'onChange', // 変更時にバリデーション実行
  });

  const username = watch('username');

  // ユーザー名の可用性チェック
  useEffect(() => {
    if (username && username.length >= 3) {
      setCheckingUsername(true);
      
      // デバウンス処理
      const timer = setTimeout(async () => {
        try {
          // API呼び出しのシミュレーション
          const response = await fetch(`/api/check-username?username=${username}`);
          const data = await response.json();
          setUsernameAvailable(data.available);
        } catch (error) {
          console.error('ユーザー名チェックエラー:', error);
        } finally {
          setCheckingUsername(false);
        }
      }, 500); // 500ms後に実行

      return () => clearTimeout(timer);
    }
  }, [username]);

  const onSubmit = (data) => {
    console.log('送信データ:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">ユーザー名</label>
        <input
          id="username"
          {...register('username')}
          placeholder="ユーザー名"
        />
        
        {/* バリデーションエラー */}
        {errors.username && (
          <span style={{ color: 'red' }}>
            {errors.username.message}
          </span>
        )}
        
        {/* 可用性チェック */}
        {checkingUsername && (
          <span style={{ color: 'blue' }}>
            確認中...
          </span>
        )}
        
        {usernameAvailable === false && (
          <span style={{ color: 'red' }}>
            このユーザー名は既に使用されています
          </span>
        )}
        
        {usernameAvailable === true && (
          <span style={{ color: 'green' }}>
            このユーザー名は利用可能です
          </span>
        )}
      </div>

      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          placeholder="メールアドレス"
        />
        {errors.email && (
          <span style={{ color: 'red' }}>
            {errors.email.message}
          </span>
        )}
      </div>

      <button type="submit">送信</button>
    </form>
  );
}

この実装では、mode: 'onChange'で入力値が変わるたびにバリデーションを実行しています。

watchでユーザー名の入力値を監視し、デバウンス処理でAPI呼び出しを制御しています。 ユーザーは入力中に即座にフィードバックを受けられるんです。

大規模フォームでも快適!パフォーマンス最適化

大規模なフォームでパフォーマンスを維持するための手法を紹介します。

「フォームが大きくなると重くなる...」この悩みを解決していきましょう。

セクション分割と遅延読み込み

大規模フォームをセクションに分けて、必要な時だけ読み込む手法です。

import React, { Suspense, lazy, useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// セクションごとのコンポーネントを遅延読み込み
const PersonalInfoSection = lazy(() => import('./PersonalInfoSection'));
const AddressSection = lazy(() => import('./AddressSection'));
const PreferencesSection = lazy(() => import('./PreferencesSection'));

function LargeFormOptimized() {
  const methods = useForm({
    resolver: zodResolver(largeFormSchema),
    mode: 'onBlur', // フォーカスが外れた時のみバリデーション
  });

  const [currentSection, setCurrentSection] = useState(0);

  const sections = [
    { name: '個人情報', component: PersonalInfoSection },
    { name: '住所情報', component: AddressSection },
    { name: '設定', component: PreferencesSection },
  ];

  const onSubmit = (data) => {
    console.log('フォームデータ:', data);
  };

  const nextSection = async () => {
    // 現在のセクションのバリデーションを実行
    const isValid = await methods.trigger();
    if (isValid && currentSection < sections.length - 1) {
      setCurrentSection(currentSection + 1);
    }
  };

  const prevSection = () => {
    if (currentSection > 0) {
      setCurrentSection(currentSection - 1);
    }
  };

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        {/* プログレスバー */}
        <div style={{ marginBottom: '20px' }}>
          <div style={{ 
            width: '100%', 
            height: '8px', 
            backgroundColor: '#e0e0e0',
            borderRadius: '4px'
          }}>
            <div style={{
              width: `${((currentSection + 1) / sections.length) * 100}%`,
              height: '100%',
              backgroundColor: '#007bff',
              borderRadius: '4px',
              transition: 'width 0.3s ease'
            }} />
          </div>
          <p>ステップ {currentSection + 1} / {sections.length}: {sections[currentSection].name}</p>
        </div>

        {/* 現在のセクションを表示 */}
        <Suspense fallback={<div>読み込み中...</div>}>
          {React.createElement(sections[currentSection].component)}
        </Suspense>

        {/* ナビゲーションボタン */}
        <div style={{ marginTop: '20px', display: 'flex', justifyContent: 'space-between' }}>
          <button
            type="button"
            onClick={prevSection}
            disabled={currentSection === 0}
            style={{
              padding: '10px 20px',
              backgroundColor: currentSection === 0 ? '#ccc' : '#6c757d',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: currentSection === 0 ? 'not-allowed' : 'pointer'
            }}
          >
            前へ
          </button>

          {currentSection === sections.length - 1 ? (
            <button
              type="submit"
              style={{
                padding: '10px 20px',
                backgroundColor: '#28a745',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              送信
            </button>
          ) : (
            <button
              type="button"
              onClick={nextSection}
              style={{
                padding: '10px 20px',
                backgroundColor: '#007bff',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              次へ
            </button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}

この実装では、lazyでコンポーネントを遅延読み込みしています。

mode: 'onBlur'でバリデーションの頻度を抑え、プログレスバーで進捗を可視化しています。 ユーザーは次のステップに進む前に、現在のセクションのバリデーションが実行されるんです。

まとめ:タイプセーフなフォーム開発を楽しみましょう!

お疲れ様でした! React Hook FormとZodを組み合わせた型安全なフォームバリデーションについて、詳しく学んできました。

この記事で学んだ重要ポイント

  • 基本的な統合方法: @hookform/resolvers/zodで簡単に連携
  • スキーマ定義: バリデーションルールと型を一箷所で管理
  • 高度な機能: 複合バリデーション、リアルタイムチェック
  • UX改善: カスタムエラーメッセージ、パフォーマンス最適化

実践的な応用例

  • ユーザー登録フォームの完全実装
  • セクション分割での大規模フォーム対応
  • リアルタイムバリデーションとAPI連携

React Hook FormとZodの組み合わせは、フォーム開発の効率性と品質を大幅に向上させます。

型安全性を保ちながら、保守性の高いフォームを構築できるため、プロジェクトの規模に関わらず活用できる手法です。

「フォーム開発がこんなに楽しくなるなんて!」という驚きを、ぜひ体験してみてください。

ぜひこの記事を参考にして、実際のプロジェクトでReact Hook FormとZodを活用してみてくださいね!

関連記事