📄
概念 📚 beginner-stepup

フォーム検証(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, "入力してください") のように日本語を明示

参考

生きているコード

本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。

  • 対応 Week: W11
  • 参照ブランチ:
  • W11: reference/week-11
  • 対応 checklist 項目: M1

ブランチの作り方・見方は b00-curriculum-map を参照してください。

  1. 1. 📄環境構築の段階的導入(macOS / Windows)
  2. 2. 📄SNSアプリの最終インフラ構成図
  3. 3. 📄SNSクローンの全体設計(Month 1 ゴール)
  4. 4. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
  5. 5. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
  6. 6. 📄シェル・ターミナル基礎
  7. 7. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
  8. 8. 📄Git基礎
  9. 9. 📄GitHubワークフロー
  10. 10. 📄パッケージ管理(pnpm workspace)
  11. 11. 📄Webアプリアーキテクチャ全体像
  12. 12. 📄要求ヒアリングとユーザーストーリー(Month 2 Week 5)
  13. 13. 📄画面ワイヤーフレームと画面遷移図(Month 2 Week 6)
  14. 14. 📄HTTP・REST API基礎
  15. 15. 📄ER 図の描き方(Month 2 Week 7)
  16. 16. 📄認証・認可のパターン
  17. 17. 📄REST API 仕様書の書き方(Month 2 Week 7)
  18. 18. 📄HTML/CSS基礎とレイアウト
  19. 19. 📄JavaScript基礎(Pythonとの対比)
  20. 20. 📄TypeScript基礎(型システムとPythonとの対比)
  21. 21. 📄Reactコンポーネント設計の基礎
  22. 22. 📄状態管理の概念
  23. 23. 📄フォーム検証(React Hook Form + Zod)(Month 3 Week 11)
  24. 24. 📄バックエンドAPI設計(Hono)
  25. 25. 📄ルーティング(React Router v7)
  26. 26. 📄Hono のエラーハンドリング(Month 4 Week 13)
  27. 27. 📄データベース設計・SQL→Drizzle ORM対応
  28. 28. 📄マイグレーションの考え方
  29. 29. 📄AWSインフラ基礎
  30. 30. 📄AWS Budget Alert の設定(Month 5 Week 17)
  31. 31. 📄環境変数管理
  32. 32. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
  33. 33. 📄CI/CD基礎
  34. 34. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
  35. 35. 📄テスト設計の基本
  36. 36. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
  37. 37. 📄CLAUDE.md・プロジェクト設定
  38. 38. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
  39. 39. 📄タスク分解・仕様の書き方
  40. 40. 📄Playwright で E2E テスト(Month 6 Week 22)
  41. 41. 📄生成コードのレビュー・デバッグの勘所
  42. 42. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
  43. 43. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
  44. 44. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)