TypeScript × react-hook-form × zod 入門 【第3回:実践編】
私はNext.js
とtailwindcss
を使用し、フロントエンド開発を行っている初心者です。フォーム開発において、型安全性の確保とバリデーションの実装を同時に行う際の複雑さに苦労していました。そこで、第1回で紹介したreact-hook-form
と第2回で紹介したzod
を組み合わせ、より堅牢なフォーム実装について解説していきます。
- 第1回:react-hook-formの基本実装
- 第2回:zodによるバリデーション
- 第3回:react-hook-formとzodの連携(本記事)
目次
はじめに
フォーム開発において、型安全性とバリデーションの両立は重要な課題です。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/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
で一箇所に集約できるため、コードの見通しが良くなり、メンテナンス性が向上します -
簡潔なコード
バリデーション処理を自分で実装する必要がなくなり、より少ないコードでフォームを実装できます。
基本的な使用例
フォームの基本実装
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;
コードの解説
このコードの主要なポイントを解説します。
-
型の生成と活用
-
z.infer<typeof schema>
を使用して、スキーマから型を自動生成します - 生成された型を
useForm
の型パラメータとして使用することで、完全な型安全性を実現します
-
-
zod
スキーマとreact-hook-form
の連携-
zodResolver
を使用して、zod
のスキーマをreact-hook-form
のバリデーションシステムと接続します - スキーマから自動的に型が生成され、フォームの型安全性が確保されます
-
-
バリデーションとエラー処理
-
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;
};
トラブルシューティング
よくあるエラーと解決方法
-
型の不一致エラー
// ❌ 問題のあるコード:数値の型変換が行われない 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
のスキーマに合わせて変換できます
- 問題のあるコードでは、
-
非同期バリデーションの扱い
// 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
を利用して、非同期バリデーションが組み込まれ、エラーメッセージを設定することで、ユーザーに対してわかりやすいフィードバックを提供できます。この全体の流れで、ユーザー体験の向上と間違った操作の防止を同時に行えます
実装時の注意点
以下の点に注意して実装を進めると良いかと存じます。
- スキーマの再利用性を考慮した設計
- バリデーションの実行タイミングの適切な設定
- エラーメッセージの一貫性の確保
- パフォーマンスを考慮したバリデーションの実装
発展的な使用例
動的フォームの実装
// フォームの配列操作用の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-form
とzod
の連携は、型安全性とバリデーションを統合的に扱える強力な組み合わせです。zod
スキーマから自動生成される型定義により、開発時のエラーを早期に発見でき、react-hook-form
の効率的なフォーム管理と組み合わせることで、メンテナンス性の高い堅牢なフォーム実装が可能になります。特に大規模なアプリケーション開発において、この組み合わせは型の一貫性維持とバリデーションロジックの集中管理を実現し、開発効率と品質の向上に大きく貢献します。
もし記事の内容に間違いや改善点がありましたら、コメントでご指摘いただけますと幸いです。また、実務での活用事例やベストプラクティスについても、ぜひ共有していただければと思います。
例えば、より複雑なフォームでの実装パターンや、パフォーマンス最適化の手法、テスト戦略など、皆様の知見をお待ちしております。特に、マイクロバリデーションやステップフォームなど、発展的な実装についても興味があります。