🔰【初心者向け】react-hook-form × zod フォーム入門（実践編）🚀

# TypeScript × react-hook-form × zod 入門 【第3回：実践編】

私は`Next.js`と`tailwindcss`を使用し、フロントエンド開発を行っている初心者です。フォーム開発において、型安全性の確保とバリデーションの実装を同時に行う際の複雑さに苦労していました。そこで、第1回で紹介した`react-hook-form`と第2回で紹介した`zod`を組み合わせ、より堅牢なフォーム実装について解説していきます。

- [第1回：react-hook-formの基本実装](https://qiita.com/CodeLeaf/items/f4c0564293acb91139a1)
- [第2回：zodによるバリデーション](https://qiita.com/CodeLeaf/items/c6056454d68e5c33fc66)
- 第3回：react-hook-formとzodの連携（本記事）

## 目次

1. [はじめに](#はじめに)
2. [必要なパッケージ](#必要なパッケージ)
3. [@hookform/resolvers/zodについて](#hookformresolverszodについて)
4. [基本的な使用例](#基本的な使用例)
5. [主要な機能の解説](#主要な機能の解説)
6. [トラブルシューティング](#トラブルシューティング)
7. [発展的な使用例](#発展的な使用例)
8. [まとめ](#まとめ)


## はじめに

フォーム開発において、型安全性とバリデーションの両立は重要な課題です。`react-hook-form`は効率的なフォーム管理を提供し、`zod`は型定義とバリデーションを統合的に扱えますが、これらを組み合わせることで、さらに強力なフォーム実装が可能になります。
今回は、この2つのライブラリを連携させる方法と、実践的なフォーム実装のパターンについて、具体的な例を交えながら解説していきます。特に、型の自動生成とバリデーションの統合に焦点を当て、メンテナンス性の高いフォーム開発の手法を紹介します。

## 必要なパッケージ

### npm
```bash
npm install react-hook-form zod @hookform/resolvers/zod
```

### yarn
```bash
yarn add react-hook-form zod @hookform/resolvers/zod
```

## `@hookform/resolvers/zod`について
`@hookform/resolvers/zod`は`react-hook-form`と`zod`を連携させるために必要なライブラリです。 
このライブラリを使うことで、`zod`で定義したスキーマ（データの型とバリデーションルール）を`react-hook-form`のバリデーション機能に、適用できるようになります。 
簡単に言うと、「`zod`で定義したルールに従って、フォームの入力値をチェックする」という役割を担っています。

### `zodResolver`について
`zodResolver`は、`zod`で定義したバリデーションルールを`react-hook-form`に適用するための関数です。フォームの入力値を`zod`スキーマに基づいてチェックし、バリデーション結果をフォームのエラーメッセージとして提供します。このため、`zod`による型安全性やバリデーションの厳密さをそのまま`react-hook-form`に持ち込むことができます。

#### 詳細な説明
`react-hook-form`は、フォームの状態管理や送信処理を効率的に行うためのライブラリですが、バリデーションの仕組みはそれ自体には含まれていません。

一方、`zod`は、データの型定義とバリデーションを統合的に行うためのライブラリですが、`react-hook-form`のようなフォーム管理機能は持っていません。

そこで、`@hookform/resolvers/zod`が登場します。このライブラリは、両者の橋渡し役とな
り、`zod`で定義したバリデーションルールを`react-hook-form`のフォームで使えるようにします。

### メリット
- **型安全性の向上**
    `zod`で定義したスキーマから`TypeScript`の型が自動生成されるため、フォームの入力値に対する型のミスマッチを防ぎ、より安全な開発ができます
- **バリデーションの一元管理**
    フォームのバリデーションルールを`zod`で一箇所に集約できるため、コードの見通しが良くなり、メンテナンス性が向上します
- **簡潔なコード**
    バリデーション処理を自分で実装する必要がなくなり、より少ないコードでフォームを実装できます。

## 基本的な使用例

### フォームの基本実装

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

// zodスキーマのバリデーションをreact-hook-formで使うためのzodResolverをインポート
import { zodResolver } from '@hookform/resolvers/zod';

// データ検証ライブラリZodからすべての関数をインポート
import { z } from 'zod';

// フォームのスキーマ定義
const userFormSchema = z.object({
  username: z.string()
    .min(3, "ユーザー名は3文字以上で入力してください")
    .max(20, "ユーザー名は20文字以内で入力してください"),
  email: z.string()
    .email("無効なメールアドレス形式です"),
  age: z.number()
    .min(18, "18歳以上である必要があります")
    .max(120, "有効な年齢を入力してください")
});

// zodのinferを使い、自動的に型UserFormInputを生成
type UserFormInput = z.infer<typeof userFormSchema>;

// フォームコンポーネントを定義（フォームの状態管理とバリデーションの設定）
const UserForm = () => {
  // useFormフックからregister, handleSubmit, errorsを取り出す
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm<UserFormInput>({ // useFormフックをUserFormInput型で初期化
    resolver: zodResolver(userFormSchema) // zodResolverを使ってフォームのバリデーションを設定
  });

  // フォーム送信時の処理
  const onSubmit = (data: UserFormInput) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      {/* ユーザー名の入力欄 */}
      <div>
        <label htmlFor="username">ユーザー名</label>
        <input
          id="username"
          {...register("username")}
          className="w-full p-2 border rounded"
        />

        {/* ユーザー名のエラーメッセージを表示 */}
        {errors.username && (
          <span className="text-red-500">{errors.username.message}</span>
        )}
      </div>
      
      {/* メールアドレスの入力欄 */}
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          {...register("email")}
          className="w-full p-2 border rounded"
        />

        {/* メールアドレスのエラーメッセージを表示 */}
        {errors.email && (
          <span className="text-red-500">{errors.email.message}</span>
        )}
      </div>

      {/* 年齢の入力欄 */}
      <div>
        <label htmlFor="age">年齢</label>
        <input
          id="age"
          type="number"
          {...register("age", { valueAsNumber: true })}
          className="w-full p-2 border rounded"
        />

        {/* 年齢のエラーメッセージを表示 */}
        {errors.age && (
          <span className="text-red-500">{errors.age.message}</span>
        )}
      </div>

      {/* 送信ボタン */}
      <button
        type="submit"
        className="w-full bg-blue-500 text-white p-2 rounded"
      >
        送信
      </button>
    </form>
  );
};

export default UserForm;
```

### コードの解説

このコードの主要なポイントを解説します。

1. **型の生成と活用**
   - `z.infer<typeof schema>`を使用して、スキーマから型を自動生成します
   - 生成された型を`useForm`の型パラメータとして使用することで、完全な型安全性を実現します

2. **`zod`スキーマと`react-hook-form`の連携**
   - `zodResolver`を使用して、`zod`のスキーマを`react-hook-form`のバリデーションシステムと接続します
   - スキーマから自動的に型が生成され、フォームの型安全性が確保されます

3. **バリデーションとエラー処理**
   - `zod`で定義したバリデーションルールが自動的に適用されます
   - エラーメッセージは`userFormSchema`で設定した文言（例：「ユーザー名は3文字以上で入力してください」）が表示されます
   - エラーメッセージは`formState.errors`から取得でき、対応するフィールドの下に表示されます
    ※`formState.errors`: `react-hook-form`が提供するオブジェクト。どの入力フィールドにエラーがあるかと、そのエラーメッセージを格納しています

## 主要な機能の解説

### 連携のメカニズム

```typescript
// フォームの制御に必要な機能をuseFormから取り出す
const {
  // フォームの各入力フィールドを管理するための関数
  register,
  
  // フォーム送信時の処理を扱う関数
  handleSubmit,
  
  // フォームのエラーメッセージを管理するオブジェクト
  formState: { errors }  

// react-hook-formのフックを呼び出し、フォームの状態管理を開始 (FormTypeはフォームの型定義)
} = useForm<FormType>({

  // zodを使ってスキーマに基づいたバリデーションを行うためのresolverを設定
  // schemaはzodで定義されたバリデーションスキーマ
  resolver: zodResolver(schema),
  
  // フォームの初期値を設定するオプション
  defaultValues: {
    // フォームの初期値を設定（空文字に設定）
    username: '', 
    email: '',
    password: '',
  },
  // フィールドからフォーカスが外れた時にバリデーションを実行
  mode: 'onBlur',
});
```

### バリデーションモードの設定

```typescript
const {
  register,
  handleSubmit
} = useForm<FormType>({
  resolver: zodResolver(schema),
  mode: 'onChange', // 入力値が変更されるたびにバリデーション
  // または
  mode: 'onBlur', // フォーカスが外れたときにバリデーション
  // または
  mode: 'onSubmit', // フォーム送信時にバリデーション
  // または
  mode: 'onTouched', // 一度でもフォーカスが当たったフィールドのみバリデーション
});
```

### エラーハンドリングの最適化

```typescript
const Form = () => {
  const {
    register,
    formState: { errors, isSubmitting, isDirty, isValid },
    handleSubmit
  } = useForm<FormType>({
    resolver: zodResolver(schema),
    mode: 'onChange'
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('fieldName')} />
      {errors.fieldName && (
        // エラーメッセージをErrorMessageコンポーネントに渡す（エラー内容を表示）
        <ErrorMessage
          message={errors.fieldName.message}
          className="text-red-500"
        />
      )}
      <button
        type="submit"
        // フォームが変更されていない、無効、または送信中であればボタンを無効化
        disabled={!isDirty || !isValid || isSubmitting}
      >
        送信
      </button>
    </form>
  );
};

// エラーメッセージを効率的に管理するためのコンポーネント
const ErrorMessage = ({
  message, // エラーメッセージの内容
  className // エラーメッセージのスタイル
}: {
  message?: string; 
  className?: string; 
}) => {
  // エラーメッセージがある場合はp要素で表示し、ない場合はnullを返す
  return message ? <p className={className}>{message}</p> : null;
};
```

## トラブルシューティング

### よくあるエラーと解決方法

1. **型の不一致エラー**
    ```typescript
    // ❌ 問題のあるコード：数値の型変換が行われない
    const schema = z.object({
      age: z.number()
    });
    
    // フォーム側で数値として扱われない
    const { register } = useForm({
      resolver: zodResolver(schema)
    });
    
    // ✅ 解決策：useFormでフォームの登録と検証を設定
    const { register } = useForm({
      resolver: zodResolver(schema)
    });

    // 入力フィールドを数値型として扱うよう明示的に指定
    // typeを"number"に指定し、registerで"age"を登録し、`valueAsNumber: true`オプションを設定しています。
    // `valueAsNumber: true`は、入力値を数値として扱うようにreact-hook-formに指示します。
    <input
      type="number"
      {...register("age", { valueAsNumber: true })}
    />
    ```
    #### 解説
    ##### 問題点
     - 問題のあるコードでは、`register`関数に`valueAsNumber`オプションが指定されていません
     - そのため、`<input type="number">`に入力された値は、デフォルトでは文字列として扱われます
     - `zod`スキーマでは`age`プロパティが数値型として定義されているため、型不一致が発生します
     - これは、フォーム送信時にエラーが発生するか、意図しない動作を引き起こす可能性があります
    
    ##### 解決策
     - 解決策では、`register`関数を呼び出す際に`valueAsNumber: true`オプションを追加しています
     - このオプションにより、`react-hook-form`は入力された文字列を数値に変換してからフォームの値を管理します
     - これにより、`zod`スキーマで定義した型と、フォームの値の型が一致し、正しいバリデーションが行われるようになります
    
    ##### 補足
     - `valueAsNumber`オプションは、`react-hook-form`の`register`関数のオプションです
     - `type="number"`の`input`要素と組み合わせて使うことで、入力値を数値として扱うことができます
     - このオプションを使わない場合、`type`が`number`の`input`要素でも、`react-hook-form`では入力値を文字列として扱います
     - `zod`はスキーマで型を定義できますが、フォームから送信された値の型を自動で変換するわけではありません
     - `react-hook-form`の`valueAsNumber`オプションを使うことで、フォームの値の型を`Zod`のスキーマに合わせて変換できます


2. **非同期バリデーションの扱い**
```typescript
// zodスキーマの定義：ageフィールドを数値型として指定
const schema = z.object({
  age: z.number(),
  // ユーザー名フィールドの定義：非同期バリデーション付き
  username: z.string().refine(
    // 非同期バリデーション関数：ユーザー名の重複チェック
    async (value) => {
      // 非同期関数checkUsernameAvailabilityでユーザー名が利用可能かチェック
      const isAvailable = await checkUsernameAvailability(value);
       // チェックの結果を返し、バリデーションが成功するかどうかを決定
       // true: バリデーションOK, false: バリデーションエラー
      return isAvailable;
    },
    // バリデーションに失敗した場合に表示されるエラーメッセージ
    "このユーザー名は既に使用されています"
  )
});

// ❌ 問題のあるパターン：型の不一致エラー
// フォームの初期化：値が文字列として扱われてしまう
const { register } = useForm({
  resolver: zodResolver(schema)
});

// ✅ 解決策：適切な型変換とバリデーションの実装
// フォームの初期化：zodResolverでスキーマを適用
const { register, formState: { errors, isSubmitting } } = useForm({
  resolver: zodResolver(schema),
  // デフォルト値の設定（必要な場合）
  defaultValues: {
    age: 0,
    username: ''
  }
});

// フォームの入力要素：age（数値）の実装
<input
  type="number"
  {...register("age", { valueAsNumber: true })}
  // エラーがある場合のスタイル適用
  className={errors.age ? "error" : ""}
  // 送信中は入力を無効化
  disabled={isSubmitting}
/>

// フォームの入力要素：username（非同期バリデーション）の実装
<input
  type="text"
  {...register("username")}
  // エラーがある場合のスタイル適用
  className={errors.username ? "error" : ""}
  // 送信中は入力を無効化
  disabled={isSubmitting}
/>

// エラーメッセージの表示
{errors.username && (
  <span className="error-message">
    {errors.username.message}
  </span>
)}
```

#### 解説

##### 問題点
- `zod`スキーマで`age`フィールドを数値型(`z.number()`)として定義しているが、フォームのデフォルトでは文字列として扱われるため、型の不一致エラーが発生する
- `username`フィールドはユーザー名重複チェックの非同期バリデーション (`refine`)が実装されているが、エラー時の表示処理が不十分です
- フォームのエラー表示が不十分で、送信中に重複送信を防ぐ仕組みが実装されていません

##### 解決策
- `useForm`を初期化する際に`zodResolver`を使用し、`zod`スキーマを適用します。また、`register`関数でフィールドを登録する際に、`valueAsNumber: true`オプションを指定して、数値フィールドの型変換を明示的に行います
- `username`フィールドのように非同期バリデーションを含む場合は、フォームの状態管理（`isSubmitting`や`errors`など）を適切に設定し、バリデーション結果に基づいてUI（例: フィールドの無効化やエラーメッセージの表示）を更新します
- これにより、`age`フィールドの型不一致問題を解決し、`username`フィールドに対する非同期バリデーションを含む適切なフォームバリデーションを実現できます

##### 補足
- `valueAsNumber`を指定することで、`<input type="number">`の値を数値として処理でき、数値スキーマを持つフィールドとの互換性を持たせます
- `isSubmitting`をチェックすることにより、データ送信中にフォームの入力を無効化し、ユーザーが送信ボタンを連打してしまうことを防ぎます
- `zod`の`refine`を利用して、非同期バリデーションが組み込まれ、エラーメッセージを設定することで、ユーザーに対してわかりやすいフィードバックを提供できます。この全体の流れで、ユーザー体験の向上と間違った操作の防止を同時に行えます

### 実装時の注意点

以下の点に注意して実装を進めると良いかと存じます。

1. スキーマの再利用性を考慮した設計
2. バリデーションの実行タイミングの適切な設定
3. エラーメッセージの一貫性の確保
4. パフォーマンスを考慮したバリデーションの実装

## 発展的な使用例

### 動的フォームの実装

```typescript
// フォームの配列操作用のhookをインポート
import { useFieldArray } from 'react-hook-form';

const schema = z.object({
  // 住所情報の配列に関するバリデーションルールを設定
  addresses: z.array(z.object({
    // 郵便番号フィールドの検証ルール
    postalCode: z.string(),

    // 都道府県フィールドの検証ルール
    prefecture: z.string(),

    // 市区町村フィールドの検証ルール
    city: z.string()
  }))
});

// 動的フォームのメインコンポーネントを定義
const DynamicForm = () => {
  const {
    control, // フォーム要素を管理するためのオブジェクト
    register, // 入力フィールドを登録するための関数
    handleSubmit // フォーム送信を処理する関数
  } = useForm({
    resolver: zodResolver(schema),
    defaultValues: {
      // デフォルトの住所データを空の状態で設定
      addresses: [{ postalCode: '', prefecture: '', city: '' }]
    }
  });

  // useFieldArrayを使って、配列形式の入力を管理
  const { fields, append, remove } = useFieldArray({
    control, // フォームコントロール
    name: "addresses" // 管理するフィールドの名前を指定
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* フィールドをループし、各住所入力をレンダリング */}
      {fields.map((field, index) => (
         // 各住所フィールドのグループ
        <div key={field.id}>
          {/* 郵便番号入力フィールド */}
          <input {...register(`addresses.${index}.postalCode`)} />
          {/* 都道府県入力フィールド */}
          <input {...register(`addresses.${index}.prefecture`)} />
          {/* 市区町村入力フィールド */}
          <input {...register(`addresses.${index}.city`)} />
          {/* 住所フィールドの削除ボタン */}
          <button type="button" onClick={() => remove(index)}>
            削除
          </button>
        </div>
      ))}
      
      {/* 新しい住所フィールドを追加するボタン */}
      <button type="button" onClick={() => append({ postalCode: '', prefecture: '', city: '' })}>
        住所を追加
      </button>

      {/* フォーム送信ボタン */}
      <button type="submit">送信</button>
    </form>
  );
};
```

### カスタムバリデーションの統合

```typescript
// パスワードの入力値を検証するためのスキーマを定義
const schema = z.object({
  // 通常のパスワード入力欄の定義
  password: z.string(),
  // 確認用パスワード入力欄の定義
  confirmPassword: z.string()
// 2つのパスワードが一致するかをチェックするカスタムバリデーション
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません", // エラーメッセージ
  path: ["confirmPassword"] // エラーを表示する入力項目
});

// パスワード入力フォームコンポーネント
const PasswordForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors }
  } = useForm({
    resolver: zodResolver(schema)
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      // パスワード入力欄
      <input
        type="password"
        {...register("password")}
      />

      // 確認用パスワード入力欄
      <input
        type="password"
        {...register("confirmPassword")}
      />

      // バリデーションエラーがある場合にエラーメッセージ
      {errors.confirmPassword && (
        <span>{errors.confirmPassword.message}</span>
      )}

      // フォーム送信ボタン
      <button type="submit">送信</button>
    </form>
  );
};
```

## まとめ

`react-hook-form`と`zod`の連携は、型安全性とバリデーションを統合的に扱える強力な組み合わせです。`zod`スキーマから自動生成される型定義により、開発時のエラーを早期に発見でき、`react-hook-form`の効率的なフォーム管理と組み合わせることで、メンテナンス性の高い堅牢なフォーム実装が可能になります。特に大規模なアプリケーション開発において、この組み合わせは型の一貫性維持とバリデーションロジックの集中管理を実現し、開発効率と品質の向上に大きく貢献します。

もし記事の内容に間違いや改善点がありましたら、コメントでご指摘いただけますと幸いです。また、実務での活用事例やベストプラクティスについても、ぜひ共有していただければと思います。

例えば、より複雑なフォームでの実装パターンや、パフォーマンス最適化の手法、テスト戦略など、皆様の知見をお待ちしております。特に、マイクロバリデーションやステップフォームなど、発展的な実装についても興味があります。