シリーズ構成
- Part 1: RHF の仕組みを理解してから使う → [リンク]
- Part 2: バリデーション設計を実務レベルに引き上げる(本記事)
- Part 3: Controller と useFieldArray(準備中)
Part 1 では register が何をしているのか、なぜ RHF が速いのかを整理しました。
今回のテーマはバリデーションです。「動くフォーム」を作るだけなら Part 1 の内容で十分ですが、実際のプロダクトではそうはいきません。エラーをいつ出すか、どう書くか、サーバーエラーをどう扱うか——細かいところに手が届いて初めて「使えるフォーム」になります。
今回のゴール
この記事では以下を理解することを目的とします:
- rule-based と schema-based、バリデーションの 2 つのアプローチの使い分け
- Zod を使った型安全なバリデーション設計
- エラーの出し方・メッセージの書き方・アクセシビリティの基本
- cross-field validation とサーバーエラーの扱い方
バリデーションの全体像を整理する
まずは、React Hook Form におけるバリデーションの全体像を整理します。
大きく分けて、以下の 2 つのアプローチがあります。
Input → RHF → Resolver(省略可)→ Schema / Rules → Error → UI
resolver を使わない場合は register のルールが直接評価されます。Zod などの resolver を挟むと、バリデーションロジックがスキーマに委譲されます。
rule-based — register に直接書く
<input
{...register("email", {
required: "メールアドレスは必須です",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "正しいメール形式を入力してください",
},
})}
/>
追加ライブラリが不要で、小さなフォームならこれで十分です。ただしフィールドが増えるにつれ、ルールがコンポーネントのあちこちに散らばっていきます。
schema-based — Zod や Yup でまとめて定義する
const schema = z.object({
email: z
.string()
.min(1, "メールアドレスは必須です")
.email("正しいメール形式を入力してください"),
});
バリデーションのロジックがスキーマに集約されます。型定義も z.infer<typeof schema> で自動生成できるため、手書きの type FormValues = { ... } が不要になります。
どちらを選ぶか
| 項目 | rule-based | schema-based |
|---|---|---|
| 追加ライブラリ | 不要 | 必要(Zod / Yup) |
| 型安全性 | 限定的 | スキーマから自動生成できる |
| 再利用性 | 低い(フォームごとに書き直し) | 高い(API 型定義と共有できる) |
| 複雑なルール | 冗長になりやすい |
refine などで柔軟に対応できる |
| 向いているケース | 小規模・単発のフォーム | 中〜大規模・使い回しが必要なフォーム |
簡単なフォームなら register のルールでも十分です。 再利用性や保守性を考えるなら schema validation が有利です。
複数の画面で同じバリデーションロジックを使い回したい・バックエンドと型を共有したい・ルールが複雑になってきた——そのタイミングで Zod に移行するのが現実的な判断です。最初から schema-based にする必要はありません。
Zod + RHF の実装
schema-based の選択肢として Zod と Yup がよく挙げられますが、現在は Zod が採用されることが多いです。理由は次の 3 点です:
- TypeScript との親和性が高い(型推論が自然に効く)
- 型定義とバリデーションを一元化できる
- ランタイムと型の乖離が起きにくい
セットアップ
npm install zod @hookform/resolvers
実装例(登録フォーム)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "2文字以上入力してください"),
email: z.string().min(1, "必須です").email("メール形式が正しくありません"),
password: z.string().min(8, "8文字以上入力してください"),
});
// スキーマから型を生成 — 手書き不要
type FormValues = z.infer<typeof schema>;
export default function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema), // これだけで Zod に委譲される
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input placeholder="名前" {...register("name")} />
{errors.name && <p>{errors.name.message}</p>}
<input placeholder="メール" {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" placeholder="パスワード" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">登録</button>
</form>
);
}
resolver: zodResolver(schema) を渡すだけで、register 側にルールを書く必要がなくなります。スキーマが唯一のバリデーションの源泉になるので、ルールの重複や矛盾が起きません。
エラーの出し方を設計する
バリデーションロジックが正しくても、エラーの出し方が悪いと UX を損ないます。
タイミングの制御
useForm の mode オプションで制御します。
useForm({
mode: "onBlur", // フォーカスが外れたとき
})
| mode | タイミング | 向いているケース |
|---|---|---|
onSubmit |
送信ボタンを押したとき(デフォルト) | シンプルな問い合わせフォーム |
onBlur |
フィールドからフォーカスが外れたとき | 登録・設定フォーム全般 |
onChange |
入力のたびにリアルタイム | パスワード強度メーターなど |
onBlur が実務でよく選ばれる理由は、入力中に赤くなるのは煩わしく、送信後だけだとフィードバックが遅い——その中間点にあるからです。
onChange はリアルタイム検証ができる反面、入力のたびにバリデーションが走るため、重い処理(非同期チェックなど)と組み合わせるときは注意が必要です。
エラーメッセージの書き方
「何が間違っているか」だけでなく「どう直せばいいか」まで書くのが原則です。
| NG | OK |
|---|---|
| 「入力が正しくありません」 | 「メールアドレスの形式で入力してください(例: user@example.com)」 |
| 「パスワードエラー」 | 「パスワードは8文字以上で入力してください」 |
| 「必須項目です」 | 「お名前を入力してください」 |
プロダクト全体でメッセージの文体を統一することも重要です。「〜してください」で統一するか「〜が必要です」で統一するか——どちらでも構いませんが、混在するのが一番よくないです。
アクセシビリティの最低限
アクセシビリティは後回しにされがちですが、実務では必須要件になるケースも多いため、最低限の対応は最初から入れておくのが安全です。
エラーをビジュアルだけで伝えると、スクリーンリーダーユーザーに届きません。最低限 aria-describedby で input とエラーを紐づけましょう。
<input
id="email"
aria-describedby={errors.email ? "email-error" : undefined}
aria-invalid={!!errors.email}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
role="alert" をつけると、エラーが表示された瞬間にスクリーンリーダーが読み上げます。aria-invalid はフィールドが無効であることをブラウザと支援技術に伝えます。
Cross-field validation — フィールドをまたぐ検証
confirm password
const schema = z
.object({
password: z.string().min(8, "8文字以上入力してください"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"], // エラーをどのフィールドに紐づけるか
});
日付の範囲チェック
const schema = z
.object({
startDate: z.string(),
endDate: z.string(),
})
.refine((data) => new Date(data.endDate) > new Date(data.startDate), {
message: "終了日は開始日より後の日付を指定してください",
path: ["endDate"],
});
日付の比較はタイムゾーンの影響を受けるため、実務では date-fns などの日付ライブラリを使う方が安全です。new Date() による文字列比較は、環境によって意図しない挙動をすることがあります。
Zod の .refine() は複数フィールドの値をまとめて受け取れるため、条件分岐をスキーマ側に閉じ込められます。rule-based でこれをやろうとすると validate オプションを使う必要があり、コードが複雑になりがちです。
サーバーエラーを setError で扱う
クライアントのバリデーションを通過しても、API 側でエラーが返ることがあります。RHF にはそれを受け取る setError があります。フィールド単位のエラーか、フォーム全体のエラーかを明確に分けることで、UI の設計がシンプルになります。
const {
register,
handleSubmit,
setError,
formState: { errors },
} = useForm<FormValues>();
const onSubmit = async (data: FormValues) => {
try {
await registerUser(data);
} catch (err) {
if (err.code === "EMAIL_ALREADY_EXISTS") {
// 特定フィールドへのエラー
setError("email", {
type: "server",
message: "このメールアドレスはすでに登録されています",
});
} else {
// フォーム全体へのエラー
setError("root", {
type: "server",
message: "登録に失敗しました。しばらく時間をおいて再試行してください",
});
}
}
};
root はフィールドに紐づかないグローバルエラー用の特別なキーです。
{errors.root && (
<p role="alert">{errors.root.message}</p>
)}
使い分けの目安:
| ケース | 使うキー |
|---|---|
| メールが重複している、ユーザー名が使用済みなど | 該当フィールドに setError
|
| 認証エラー、ネットワークエラー、権限エラーなど |
root に setError
|
よくあるハマりどころ
実際に使い始めると詰まりやすいポイントをまとめておきます。
schema と register のルールを混在させてしまう
zodResolver を使っている場合、register 側にもルールを書くと二重管理になります。resolver を使ったら register 側のルールは書かないと決めておきましょう。
errors.xxx.message が undefined になる
Zod のスキーマでメッセージを設定し忘れているか、フィールド名がスキーマと一致していないことが多いです。z.string() だけだとデフォルトメッセージが英語になる場合があります。
defaultValues とスキーマの型が合っていない
useForm の defaultValues は型推論が効きにくく、スキーマと微妙にずれていても気づきにくいです。defaultValues も FormValues 型で明示的に指定しておくと安全です。
まとめ
-
スキーマは別ファイルに切り出す —
schema.tsに分離しておくと API 型定義やテストから再利用しやすくなります - クライアントとサーバーのバリデーションは役割が違う — クライアントは即時フィードバック、サーバーは最終チェックと割り切る
- エラーメッセージはコードと同じくらい大切 — 文体の統一・具体的な修正方法の提示・アクセシビリティの確保、この 3 点でフォームの完成度が変わります