T-CREATOR

Zod と React Hook Form を組み合わせて使う方法と実装例

Zod と React Hook Form を組み合わせて使う方法と実装例

現代のWebアプリケーション開発において、フォームバリデーションは避けて通れない重要な要素です。特にTypeScript環境では、型安全性を保ちながら効率的なバリデーション処理を実現する必要があります。

ZodとReact Hook Formの組み合わせは、この課題に対する最適解の一つとして注目されています。型安全なスキーマ定義によるバリデーション処理と、最適化されたフォーム管理を同時に実現できるこの手法について、詳しく解説いたします。

背景

フロントエンドでのフォームバリデーションの課題

フロントエンド開発において、フォームバリデーションは常に開発者を悩ませる課題でした。ユーザーが入力したデータの妥当性をチェックし、適切なエラーメッセージを表示する必要があります。

従来のアプローチでは、以下のような問題が発生していました。

  • バリデーションロジックがコンポーネント全体に散らばってしまう
  • 同じようなバリデーション処理を複数の場所で書く必要がある
  • エラー状態の管理が複雑になりがちである

TypeScript環境でのタイプセーフなバリデーション需要

TypeScriptの普及により、コンパイル時の型チェックの重要性が高まっています。しかし、実行時のデータバリデーションにおいては、型安全性を保つことが困難でした。

特に以下の場面で課題が顕著に現れます。

  • APIから取得したデータの型検証
  • ユーザー入力データの型変換と検証
  • フォームデータとTypeScript型定義の整合性確保

既存のバリデーション手法の限界

これまでのバリデーション手法では、以下のような限界がありました。

手法問題点
1手動バリデーション
2PropTypes
3Joi
4Yup

以下の図は、従来のバリデーション処理の複雑さを示しています。

mermaidflowchart TB
    user[ユーザー入力] -->|データ| component[Reactコンポーネント]
    component -->|手動チェック| validation1[フィールド1バリデーション]
    component -->|手動チェック| validation2[フィールド2バリデーション]
    component -->|手動チェック| validation3[フィールド3バリデーション]
    validation1 -->|エラー| error1[エラー状態1]
    validation2 -->|エラー| error2[エラー状態2]
    validation3 -->|エラー| error3[エラー状態3]
    error1 --> ui[UI更新]
    error2 --> ui
    error3 --> ui

このように、各フィールドごとに個別のバリデーション処理とエラー管理が必要となり、コードが複雑化してしまいます。

課題

型安全性の確保とバリデーション処理の複雑さ

TypeScript環境でのフォーム開発では、コンパイル時の型チェックと実行時のバリデーションを両立させる必要があります。しかし、この2つを統合して管理することは非常に困難でした。

具体的な課題として以下があります。

  • TypeScript型定義とバリデーションルールの二重管理
  • 型情報の不整合によるランタイムエラー
  • 複雑なオブジェクト構造での型推論の限界

コードの重複とメンテナンス性の問題

同じようなバリデーション処理を複数のコンポーネントで実装する際、コードの重複が発生しやすくなります。

以下のような問題が頻繁に発生していました。

  • 似たようなフォームで同じバリデーション処理を何度も記述
  • バリデーションルールの変更時に複数箇所の修正が必要
  • テストコードの重複とメンテナンス負荷の増大

UXを考慮したエラーハンドリングの難しさ

優れたユーザー体験を提供するためには、適切なタイミングでのバリデーション実行と、わかりやすいエラーメッセージの表示が重要です。

しかし、以下の課題により実現が困難でした。

  • リアルタイムバリデーションによるパフォーマンス低下
  • エラーメッセージの表示タイミングの制御
  • 複数フィールド間の依存関係を持つバリデーション

解決策

Zodによる型安全なスキーマ定義

Zodは、TypeScriptファーストなスキーマバリデーションライブラリです。スキーマから型を推論し、実行時バリデーションを提供します。

以下の図は、Zodによるスキーマ定義からバリデーション実行までの流れを示しています。

mermaidflowchart LR
    schema[Zodスキーマ定義] -->|型推論| types[TypeScript型]
    schema -->|実行時チェック| validation[バリデーション実行]
    data[入力データ] --> validation
    validation -->|成功| success[型安全なデータ]
    validation -->|失敗| error[詳細エラー情報]

Zodの主要な特徴は以下の通りです。

  • 型安全性: スキーマから自動的にTypeScript型を生成
  • 軽量: 最小限のバンドルサイズ
  • 豊富なバリデーター: 文字列、数値、配列など多様なデータ型に対応
  • コンポーザブル: 複雑なスキーマを小さな部品から構築可能

React Hook Formとの統合によるパフォーマンス最適化

React Hook Formは、最小限の再レンダリングでフォーム状態を管理するライブラリです。

パフォーマンス最適化の仕組みを以下に示します。

mermaidflowchart TD
    form[フォーム入力] -->|非制御コンポーネント| rhf[React Hook Form]
    rhf -->|必要な場合のみ| rerender[再レンダリング]
    rhf -->|直接参照| dom[DOM要素]
    rhf -->|バリデーション時| validation[バリデーション実行]
    validation -->|エラーのみ| ui[UI更新]

React Hook Formの主な利点は以下です。

  • 高性能: 不要な再レンダリングを最小限に抑制
  • 簡潔なAPI: useFormフックによる直感的な使用方法
  • 柔軟性: 様々なUIライブラリとの統合が容易
  • DevTools: 開発時のデバッグ支援機能

zodResolverを使ったseamlessな連携

@hookform/resolvers/zodパッケージは、ZodスキーマをReact Hook Formのバリデーションに統合するためのリゾルバーを提供します。

以下が統合のアーキテクチャです。

mermaidflowchart TB
    schema[Zodスキーマ] --> resolver[zodResolver]
    resolver --> rhf[React Hook Form]
    rhf -->|バリデーション要求| resolver
    resolver -->|Zod実行| schema
    schema -->|結果| resolver
    resolver -->|フォーマット| rhf
    rhf -->|エラー表示| ui[UIコンポーネント]

この統合により以下が実現されます。

  • 一元管理: スキーマ定義が単一の情報源となる
  • 自動型推論: フォームの型がスキーマから自動生成される
  • 統一されたエラーハンドリング: Zodのエラー形式がReact Hook Form形式に変換される

具体例

基本的な実装例(簡単なフォーム)

まず、必要なパッケージをインストールします。

typescriptyarn add zod react-hook-form @hookform/resolvers
yarn add -D @types/react

基本的なログインフォームの実装例を段階的に説明します。

Zodスキーマの定義

まず、バリデーションルールを定義します。

typescriptimport { z } from 'zod'

// ログインフォーム用のスキーマ定義
const loginSchema = z.object({
  email: z
    .string()
    .min(1, 'メールアドレスを入力してください')
    .email('正しいメールアドレス形式で入力してください'),
  password: z
    .string()
    .min(8, 'パスワードは8文字以上で入力してください')
    .regex(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      'パスワードは英大文字・小文字・数字を含む必要があります'
    )
})

// TypeScript型の自動生成
type LoginForm = z.infer<typeof loginSchema>

この段階で、スキーマ定義からTypeScript型が自動生成されます。

React Hook Formとの統合

次に、フォームコンポーネントを作成します。

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

const LoginForm: React.FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
    mode: 'onBlur' // ブラー時にバリデーション実行
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* フォーム要素は次のコードブロックで実装 */}
    </form>
  )
}

zodResolverによって、Zodスキーマがバリデーションに統合されます。

フォーム要素とエラー表示

入力フィールドとエラーメッセージの表示を実装します。

typescriptconst onSubmit = async (data: LoginForm) => {
  try {
    console.log('フォームデータ:', data)
    // API呼び出し処理をここに実装
  } catch (error) {
    console.error('送信エラー:', error)
  }
}

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <div>
      <label htmlFor="email">メールアドレス</label>
      <input
        id="email"
        type="email"
        {...register('email')}
        aria-invalid={errors.email ? 'true' : 'false'}
      />
      {errors.email && (
        <p role="alert" style={{ color: 'red' }}>
          {errors.email.message}
        </p>
      )}
    </div>

    <div>
      <label htmlFor="password">パスワード</label>
      <input
        id="password"
        type="password"
        {...register('password')}
        aria-invalid={errors.password ? 'true' : 'false'}
      />
      {errors.password && (
        <p role="alert" style={{ color: 'red' }}>
          {errors.password.message}
        </p>
      )}
    </div>

    <button type="submit" disabled={isSubmitting}>
      {isSubmitting ? 'ログイン中...' : 'ログイン'}
    </button>
  </form>
)

このように、registerメソッドでフィールドを登録し、errorsオブジェクトからエラーメッセージを取得できます。

複雑なフォーム(ネストしたオブジェクト、配列)

より複雑なユーザー登録フォームの実装例を見ていきます。

複雑なスキーマ定義

ネストしたオブジェクトと配列を含むスキーマを定義します。

typescriptconst userRegistrationSchema = z.object({
  // 基本情報
  basicInfo: z.object({
    firstName: z.string().min(1, '姓を入力してください'),
    lastName: z.string().min(1, '名を入力してください'),
    birthDate: z.string().refine(
      (date) => new Date(date) < new Date(),
      '有効な生年月日を入力してください'
    )
  }),
  
  // 連絡先情報
  contact: z.object({
    email: z.string().email('正しいメールアドレスを入力してください'),
    phone: z.string().regex(
      /^0\d{1,4}-\d{1,4}-\d{4}$/,
      '正しい電話番号形式で入力してください(例: 03-1234-5678)'
    )
  }),
  
  // 趣味リスト(配列)
  hobbies: z.array(
    z.object({
      name: z.string().min(1, '趣味名を入力してください'),
      level: z.enum(['beginner', 'intermediate', 'advanced'])
    })
  ).min(1, '少なくとも1つの趣味を入力してください')
})

type UserRegistrationForm = z.infer<typeof userRegistrationSchema>

このスキーマでは、オブジェクトの階層化と配列の使用を組み合わせています。

動的フィールドの実装

配列フィールドを動的に追加・削除できる実装例です。

typescriptimport { useFieldArray } from 'react-hook-form'

const UserRegistrationForm: React.FC = () => {
  const {
    register,
    control,
    handleSubmit,
    formState: { errors }
  } = useForm<UserRegistrationForm>({
    resolver: zodResolver(userRegistrationSchema),
    defaultValues: {
      hobbies: [{ name: '', level: 'beginner' }]
    }
  })

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'hobbies'
  })

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 基本情報フィールドは次のブロックで実装 */}
    </form>
  )
}

useFieldArrayフックにより、配列フィールドを効率的に管理できます。

ネストしたフィールドの実装

基本情報セクションの実装例です。

typescript{/* 基本情報セクション */}
<fieldset>
  <legend>基本情報</legend>
  
  <div>
    <label htmlFor="basicInfo.firstName"></label>
    <input
      id="basicInfo.firstName"
      {...register('basicInfo.firstName')}
    />
    {errors.basicInfo?.firstName && (
      <p style={{ color: 'red' }}>
        {errors.basicInfo.firstName.message}
      </p>
    )}
  </div>

  <div>
    <label htmlFor="basicInfo.lastName"></label>
    <input
      id="basicInfo.lastName"
      {...register('basicInfo.lastName')}
    />
    {errors.basicInfo?.lastName && (
      <p style={{ color: 'red' }}>
        {errors.basicInfo.lastName.message}
      </p>
    )}
  </div>

  <div>
    <label htmlFor="basicInfo.birthDate">生年月日</label>
    <input
      id="basicInfo.birthDate"
      type="date"
      {...register('basicInfo.birthDate')}
    />
    {errors.basicInfo?.birthDate && (
      <p style={{ color: 'red' }}>
        {errors.basicInfo.birthDate.message}
      </p>
    )}
  </div>
</fieldset>

ドット記法により、ネストしたフィールドに簡単にアクセスできます。

動的配列フィールドの実装

趣味リストを動的に管理する部分の実装です。

typescript{/* 趣味セクション */}
<fieldset>
  <legend>趣味</legend>
  
  {fields.map((field, index) => (
    <div key={field.id} style={{ border: '1px solid #ddd', padding: '1rem', margin: '0.5rem 0' }}>
      <div>
        <label htmlFor={`hobbies.${index}.name`}>趣味名</label>
        <input
          {...register(`hobbies.${index}.name`)}
          placeholder="例: 読書"
        />
        {errors.hobbies?.[index]?.name && (
          <p style={{ color: 'red' }}>
            {errors.hobbies[index]?.name?.message}
          </p>
        )}
      </div>

      <div>
        <label htmlFor={`hobbies.${index}.level`}>レベル</label>
        <select {...register(`hobbies.${index}.level`)}>
          <option value="beginner">初心者</option>
          <option value="intermediate">中級者</option>
          <option value="advanced">上級者</option>
        </select>
      </div>

      <button
        type="button"
        onClick={() => remove(index)}
        disabled={fields.length === 1}
      >
        削除
      </button>
    </div>
  ))}

  <button
    type="button"
    onClick={() => append({ name: '', level: 'beginner' })}
  >
    趣味を追加
  </button>
  
  {errors.hobbies && (
    <p style={{ color: 'red' }}>{errors.hobbies.message}</p>
  )}
</fieldset>

このように、配列の各要素に対してバリデーションとエラー表示を個別に設定できます。

非同期バリデーション

サーバーサイドでの重複チェックなど、非同期バリデーションの実装例です。

非同期バリデーション関数の定義

まず、非同期チェックを行う関数を作成します。

typescript// メールアドレスの重複チェック(API呼び出しをシミュレート)
const checkEmailExists = async (email: string): Promise<boolean> => {
  // 実際のAPIエンドポイントに置き換えてください
  return new Promise((resolve) => {
    setTimeout(() => {
      // 既存のメールアドレスリスト(実際はAPIから取得)
      const existingEmails = ['test@example.com', 'user@example.com']
      resolve(existingEmails.includes(email))
    }, 1000)
  })
}

この関数は、実際のプロジェクトではAPIエンドポイントへのリクエストに置き換えられます。

非同期バリデーションの統合

Zodスキーマに非同期バリデーションを組み込みます。

typescriptconst asyncEmailSchema = z.object({
  email: z
    .string()
    .email('正しいメールアドレス形式で入力してください')
    .refine(
      async (email) => {
        const exists = await checkEmailExists(email)
        return !exists
      },
      {
        message: 'このメールアドレスは既に使用されています',
      }
    ),
  password: z.string().min(8, 'パスワードは8文字以上で入力してください')
})

refineメソッドにasync関数を渡すことで、非同期バリデーションを実現できます。

React Hook Formでの非同期バリデーション

非同期バリデーションを含むフォームコンポーネントの実装です。

typescriptconst AsyncValidationForm: React.FC = () => {
  const {
    register,
    handleSubmit,
    formState: { errors, isValidating },
    trigger
  } = useForm<z.infer<typeof asyncEmailSchema>>({
    resolver: zodResolver(asyncEmailSchema),
    mode: 'onBlur'
  })

  const onEmailBlur = async () => {
    // メールフィールドのバリデーションを手動トリガー
    await trigger('email')
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register('email')}
          onBlur={onEmailBlur}
        />
        {isValidating && <p>確認中...</p>}
        {errors.email && (
          <p style={{ color: 'red' }}>{errors.email.message}</p>
        )}
      </div>
      
      <button type="submit">送信</button>
    </form>
  )
}

isValidatingフラグにより、バリデーション中の状態をユーザーに表示できます。

カスタムバリデーション

特定のビジネスルールに対応するカスタムバリデーションの実装例です。

パスワード確認のカスタムバリデーション

パスワードと確認パスワードが一致することをチェックするスキーマです。

typescriptconst passwordConfirmSchema = z
  .object({
    password: z
      .string()
      .min(8, 'パスワードは8文字以上で入力してください')
      .regex(
        /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/,
        'パスワードは英大文字・小文字・数字・記号を含む必要があります'
      ),
    confirmPassword: z.string()
  })
  .refine(
    (data) => data.password === data.confirmPassword,
    {
      message: 'パスワードが一致しません',
      path: ['confirmPassword'] // エラーを表示するフィールドを指定
    }
  )

refineメソッドを使用して、複数フィールド間の関係をバリデーションできます。

条件付きバリデーション

特定の条件下でのみ適用されるバリデーションの実装例です。

typescriptconst conditionalSchema = z.object({
  accountType: z.enum(['personal', 'business']),
  companyName: z.string().optional(),
  taxNumber: z.string().optional()
}).refine(
  (data) => {
    // ビジネスアカウントの場合、会社名と税務番号が必須
    if (data.accountType === 'business') {
      return data.companyName && data.companyName.length > 0 &&
             data.taxNumber && data.taxNumber.length > 0
    }
    return true
  },
  {
    message: 'ビジネスアカウントでは会社名と税務番号が必須です',
    path: ['companyName'] // 最初のエラー表示フィールド
  }
)

このように、アカウントタイプによって必須フィールドを動的に変更できます。

フィールド依存バリデーションの実装

条件付きバリデーションを使用するフォームコンポーネントです。

typescriptconst ConditionalForm: React.FC = () => {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors }
  } = useForm<z.infer<typeof conditionalSchema>>({
    resolver: zodResolver(conditionalSchema)
  })

  const accountType = watch('accountType')

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>アカウントタイプ</label>
        <select {...register('accountType')}>
          <option value="personal">個人</option>
          <option value="business">法人</option>
        </select>
      </div>

      {accountType === 'business' && (
        <>
          <div>
            <label htmlFor="companyName">会社名</label>
            <input
              id="companyName"
              {...register('companyName')}
              required={accountType === 'business'}
            />
            {errors.companyName && (
              <p style={{ color: 'red' }}>{errors.companyName.message}</p>
            )}
          </div>

          <div>
            <label htmlFor="taxNumber">税務番号</label>
            <input
              id="taxNumber"
              {...register('taxNumber')}
              required={accountType === 'business'}
            />
            {errors.taxNumber && (
              <p style={{ color: 'red' }}>{errors.taxNumber.message}</p>
            )}
          </div>
        </>
      )}

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

watchメソッドにより、他のフィールドの値に基づいて表示内容を動的に変更できます。

まとめ

導入のメリット

ZodとReact Hook Formの組み合わせは、現代のフロントエンド開発において多くのメリットをもたらします。

型安全性の向上

  • スキーマ定義からTypeScript型が自動生成される
  • コンパイル時と実行時の両方で一貫した型チェックが可能
  • 型の不整合によるバグを事前に防げる

開発効率の向上

  • 一つのスキーマ定義で複数の用途に対応
  • コードの重複を大幅に削減
  • 豊富なバリデーターによる迅速な開発

パフォーマンス最適化

  • 最小限の再レンダリングによる高速なフォーム処理
  • 効率的なバリデーション実行
  • 大規模フォームでも優れたパフォーマンスを維持

保守性の向上

  • 中央集権化されたバリデーションロジック
  • 一箇所の変更で全体に影響を与えられる
  • テストしやすい構造

導入時の注意点

この組み合わせを効果的に活用するために、以下の点にご注意ください。

学習コスト

  • Zodの記法とReact Hook Formの概念を理解する必要がある
  • 既存コードからの移行には計画的なアプローチが必要

バンドルサイズ

  • 小規模プロジェクトでは相対的にオーバーヘッドが大きい場合がある
  • Tree Shakingを活用して不要な機能を除外することが重要

非同期バリデーション

  • 適切なUX設計を行わないとユーザー体験を損なう可能性がある
  • デバウンス処理やローディング状態の管理が重要

今後の活用方針

ZodとReact Hook Formの組み合わせは、以下の場面で特に威力を発揮します。

推奨される活用場面

  • 複雑なフォームを含むWebアプリケーション
  • TypeScriptを使用したプロジェクト
  • フロントエンド・バックエンドでスキーマを共有したい場合
  • 高いパフォーマンス要求があるプロジェクト

段階的導入アプローチ

  1. 新しいフォームから順次適用を開始
  2. 既存フォームは優先度に応じて段階的に移行
  3. チーム内での知識共有とベストプラクティスの確立
  4. プロジェクト全体でのコーディング規約への組み込み

このアプローチにより、リスクを最小限に抑えながら、長期的な開発効率とコード品質の向上を実現できるでしょう。

関連リンク

公式ドキュメント

参考資料

実用的なリソース