フォーム検証(React Hook Form + Zod)(Month 3 Week 11)
React Hook Form で入力管理を行い、Zod スキーマを共有してクライアント・サーバー両方で同じバリデーションを適用する方法
Week 11 では、Week 2 で触れた useState ベースのフォームを React Hook Form + Zod に昇格させます。ユーザー入力は「最も事故が起きる場所」であり、ここを雑にするとバックエンドの防御もザルになります。
なぜ React Hook Form か
useState でフォームを書くと、入力欄が増えるほど state と onChange が増殖します。
// useState だけで書くと…
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [titleError, setTitleError] = useState("");
const [contentError, setContentError] = useState("");
// … 増え続ける
React Hook Form は以下を一括で解決します:
- 入力値の管理(内部で ref を使い、再レンダリングを最小化)
- バリデーション実行タイミング(onChange / onBlur / onSubmit を選べる)
- エラーメッセージの表示
- 送信時のシリアライズ
Zod とつなぐ理由
Zod は TypeScript-first のバリデーションライブラリです。同じスキーマ定義を API 側でも再利用 できます。
┌─────────────────────┐
│ shared/schemas.ts │ ← 1 箇所で定義
│ createPostSchema │
└────┬────────────────┘
↓
┌──┴──┬────────────┐
↓ ↓ ↓
[web] [api/routes] [api/tests]
フロントとバックの検証ルールがズレる事故を構造的に防げます。
セットアップ
cd web
pnpm add react-hook-form @hookform/resolvers zod
Month 2 で使った Zod(b13-api-design)がそのまま活きます。追加で react-hook-form と、Zod を RHF に橋渡しする @hookform/resolvers を入れます。
実装例:投稿作成フォーム
Step 1: スキーマ定義
api/src/schemas/post.ts(または共通化したい場合は shared/ に置く):
import { z } from "zod";
export const createPostSchema = z.object({
content: z
.string()
.min(1, "本文を入力してください")
.max(280, "本文は 280 文字以内で入力してください"),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
z.infer でスキーマから TypeScript 型を自動生成できるため、手書きで型を二重管理しません。
Step 2: フロント側のフォーム
web/src/components/PostForm.tsx:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createPostSchema, type CreatePostInput } from "../schemas/post";
import { fetchApi } from "../lib/api";
export function PostForm({ onCreated }: { onCreated: () => void }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<CreatePostInput>({
resolver: zodResolver(createPostSchema),
mode: "onBlur",
});
async function onSubmit(data: CreatePostInput) {
await fetchApi("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
reset();
onCreated();
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
<textarea
{...register("content")}
className="w-full border rounded p-2"
placeholder="いま何してる?"
rows={3}
/>
{errors.content && (
<p className="text-red-500 text-sm">{errors.content.message}</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isSubmitting ? "送信中…" : "投稿"}
</button>
</form>
);
}
Step 3: バック側でも同じスキーマで検証
api/src/routes/posts.ts:
import { zValidator } from "@hono/zod-validator";
import { createPostSchema } from "../schemas/post";
postsRouter.post("/", authMiddleware, zValidator("json", createPostSchema), async (c) => {
const { content } = c.req.valid("json");
// content は既に検証済み・型付き
// ...
});
フロントでバリデーションを通したから、バックでは不要 という発想は危険です。curl や他のクライアントから直接叩かれる可能性があるため、API 側の検証は常に必須です。
よく使うパターン
パターン 1: ログインフォーム(2 フィールド)
export const loginSchema = z.object({
email: z.string().email("メールアドレスの形式が正しくありません"),
password: z.string().min(8, "パスワードは 8 文字以上で入力してください"),
});
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(loginSchema),
});
<input {...register("email")} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
パターン 2: パスワード確認(cross-field validation)
export const registerSchema = z
.object({
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"],
});
refine で 2 フィールド間の整合性を検証。エラーは confirmPassword フィールドに紐付けます。
パターン 3: 条件付きフィールド
「自己紹介は任意。入力する場合は 10 文字以上」
export const profileSchema = z.object({
name: z.string().min(1).max(50),
bio: z.string().min(10).max(500).optional().or(z.literal("")),
});
サーバーエラーをフォームに反映する
API が 400 を返した場合、フォーム側にフィードバックします:
const { setError } = useForm<CreatePostInput>({
resolver: zodResolver(createPostSchema),
});
async function onSubmit(data: CreatePostInput) {
try {
await fetchApi("/api/posts", { method: "POST", body: JSON.stringify(data) });
} catch (err) {
if (err instanceof ApiError && err.code === "VALIDATION_FAILED") {
// b14-error-handling の詳細レスポンス形式に合わせる
for (const [field, messages] of Object.entries(err.details.fieldErrors)) {
setError(field as keyof CreatePostInput, {
type: "server",
message: (messages as string[])[0],
});
}
return;
}
// その他のエラー
setError("root", { message: "送信に失敗しました。しばらく待ってから再試行してください" });
}
}
パフォーマンスの注意
React Hook Form は ref ベースで動くため、register 経由の input は再レンダリングの対象外です。ただし以下は避けます:
watch()を親コンポーネントで全フィールドに呼ぶ → 入力ごとに全体が再レンダ- 条件付きレンダで
registerを外す → submit 時にフィールドが抜ける
必要な場合は Controller コンポーネントを使い、個別に再レンダ範囲を制御します(MUI などの制御コンポーネントと組み合わせる際)。
Week 11 のアウトプット
- ☐ React Hook Form + Zod がインストールされている
- ☐ 少なくとも 1 つのフォームが
useStateから React Hook Form に移行されている - ☐ スキーマが
schemas/ディレクトリに集約されている - ☐ API 側も同じスキーマで
zValidatorを通している - ☐
errors.<field>.messageが画面に表示される - ☐ サーバーエラー(400)もフォームに反映される
よくある失敗
- フロントでだけ検証する: curl で空 content を送ると 500 エラー。API でも必ず検証
- 型を手書きで二重管理:
CreatePostInputを interface で別途書くと、スキーマとずれる。z.inferを使う - onSubmit 内で try-catch を忘れる: fetch が throw したら UI が固まる
- disabled 制御を
isSubmittingでしない: 二重送信が起きる - エラーメッセージを英語のまま出す: Zod デフォルトは英語。
z.string().min(1, "入力してください")のように日本語を明示
参考
- React Hook Form 公式: https://react-hook-form.com/
- Zod 公式: https://zod.dev/
- @hookform/resolvers: https://github.com/react-hook-form/resolvers
- 前提: b11-react-components(コンポーネント設計)/ b12-state-management(状態管理)
- 対になる教材: b14-error-handling(サーバー側のエラー設計)
生きているコード
本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。
- 対応 Week: W11
- 参照ブランチ:
- W11:
reference/week-11 - 対応 checklist 項目: M1
ブランチの作り方・見方は b00-curriculum-map を参照してください。
- 1. 📄環境構築の段階的導入(macOS / Windows)
- 2. 📄SNSアプリの最終インフラ構成図
- 3. 📄SNSクローンの全体設計(Month 1 ゴール)
- 4. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
- 5. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
- 6. 📄シェル・ターミナル基礎
- 7. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
- 8. 📄Git基礎
- 9. 📄GitHubワークフロー
- 10. 📄パッケージ管理(pnpm workspace)
- 11. 📄Webアプリアーキテクチャ全体像
- 12. 📄要求ヒアリングとユーザーストーリー(Month 2 Week 5)
- 13. 📄画面ワイヤーフレームと画面遷移図(Month 2 Week 6)
- 14. 📄HTTP・REST API基礎
- 15. 📄ER 図の描き方(Month 2 Week 7)
- 16. 📄認証・認可のパターン
- 17. 📄REST API 仕様書の書き方(Month 2 Week 7)
- 18. 📄HTML/CSS基礎とレイアウト
- 19. 📄JavaScript基礎(Pythonとの対比)
- 20. 📄TypeScript基礎(型システムとPythonとの対比)
- 21. 📄Reactコンポーネント設計の基礎
- 22. 📄状態管理の概念
- 23. 📄フォーム検証(React Hook Form + Zod)(Month 3 Week 11)
- 24. 📄バックエンドAPI設計(Hono)
- 25. 📄ルーティング(React Router v7)
- 26. 📄Hono のエラーハンドリング(Month 4 Week 13)
- 27. 📄データベース設計・SQL→Drizzle ORM対応
- 28. 📄マイグレーションの考え方
- 29. 📄AWSインフラ基礎
- 30. 📄AWS Budget Alert の設定(Month 5 Week 17)
- 31. 📄環境変数管理
- 32. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
- 33. 📄CI/CD基礎
- 34. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
- 35. 📄テスト設計の基本
- 36. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
- 37. 📄CLAUDE.md・プロジェクト設定
- 38. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
- 39. 📄タスク分解・仕様の書き方
- 40. 📄Playwright で E2E テスト(Month 6 Week 22)
- 41. 📄生成コードのレビュー・デバッグの勘所
- 42. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
- 43. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
- 44. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)