0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-01-16

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

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

目次

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

はじめに

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

必要なパッケージ

npm

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

yarn

yarn add react-hook-form zod @hookform/resolvers/zod

@hookform/resolvers/zodについて

@hookform/resolvers/zodreact-hook-formzodを連携させるために必要なライブラリです。
このライブラリを使うことで、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で一箇所に集約できるため、コードの見通しが良くなり、メンテナンス性が向上します
  • 簡潔なコード
    バリデーション処理を自分で実装する必要がなくなり、より少ないコードでフォームを実装できます。

基本的な使用例

フォームの基本実装

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が提供するオブジェクト。どの入力フィールドにエラーがあるかと、そのエラーメッセージを格納しています

主要な機能の解説

連携のメカニズム

// フォームの制御に必要な機能を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',
});

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

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

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

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. 型の不一致エラー

    // ❌ 問題のあるコード:数値の型変換が行われない
    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-formregister関数のオプションです
    • type="number"input要素と組み合わせて使うことで、入力値を数値として扱うことができます
    • このオプションを使わない場合、typenumberinput要素でも、react-hook-formでは入力値を文字列として扱います
    • zodはスキーマで型を定義できますが、フォームから送信された値の型を自動で変換するわけではありません
    • react-hook-formvalueAsNumberオプションを使うことで、フォームの値の型をZodのスキーマに合わせて変換できます
  2. 非同期バリデーションの扱い

// 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フィールドのように非同期バリデーションを含む場合は、フォームの状態管理(isSubmittingerrorsなど)を適切に設定し、バリデーション結果に基づいてUI(例: フィールドの無効化やエラーメッセージの表示)を更新します
  • これにより、ageフィールドの型不一致問題を解決し、usernameフィールドに対する非同期バリデーションを含む適切なフォームバリデーションを実現できます
補足
  • valueAsNumberを指定することで、<input type="number">の値を数値として処理でき、数値スキーマを持つフィールドとの互換性を持たせます
  • isSubmittingをチェックすることにより、データ送信中にフォームの入力を無効化し、ユーザーが送信ボタンを連打してしまうことを防ぎます
  • zodrefineを利用して、非同期バリデーションが組み込まれ、エラーメッセージを設定することで、ユーザーに対してわかりやすいフィードバックを提供できます。この全体の流れで、ユーザー体験の向上と間違った操作の防止を同時に行えます

実装時の注意点

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

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

発展的な使用例

動的フォームの実装

// フォームの配列操作用の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>
  );
};

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

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

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

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?