Use Cases (更新: 2026/6/2)

Claude Codeでフォームバリデーションを実装: React Hook FormとZod実践ガイド

Claude CodeでReact Hook Form、Zod、サーバー検証、i18n、テストまで実装するフォームバリデーション実践ガイド。

Claude Codeでフォームバリデーションを実装: React Hook FormとZod実践ガイド

Claude Codeに任せる前に、フォームの境界を決める

フォームバリデーションは「未入力ならエラーを出す」だけの機能ではありません。問い合わせ、無料トライアル、資料請求、決済前の住所入力、管理画面の編集フォームでは、入力ミスがそのまま売上機会の損失、サポート対応、データ不整合につながります。初心者ほど、見た目のフォームを先に作ってからエラー表示を足しがちですが、実務では逆です。最初に「どの値を正しいとみなすか」「失敗したらどこに表示するか」「サーバーでも同じ検証をするか」を決めます。

Claude Codeはこの整理と実装の両方に向いています。React Hook FormはReactのフォームを扱うライブラリ、Zodは入力ルールを書くためのスキーマ、つまりデータの契約書です。resolverは「React Hook FormとZodをつなぐ翻訳係」です。server-side validationは、ブラウザを信用せずAPI側でも検証することです。normalizationは、Zodのエラー、業務ルールのエラー、JSONの読み取り失敗を画面で扱いやすい同じ形にそろえることです。i18nは多言語化、accessibilityはスクリーンリーダーやキーボード操作でもエラーが伝わる状態を指します。

この記事では、Claude Codeに丸投げしないための依頼文、React Hook FormとZodのコピーしやすい実装、Next.js Route Handler風のAPI検証、APIエラーの正規化、アクセシビリティ、i18n、テストまでを1つの問い合わせフォームで扱います。関連する基礎はReact Hook Form実践ガイドZodバリデーション実践ガイドも参考になります。公式仕様はClaude Code overviewReact Hook Form useFormReact Hook Form ResolversZod公式ドキュメントNext.js Route HandlersTesting Libraryを確認してください。

flowchart TD
  A["User input"] --> B["React Hook Form"]
  B --> C["zodResolver"]
  C --> D{"Client valid?"}
  D -->|No| E["Accessible field errors"]
  D -->|Yes| F["POST /api/contact"]
  F --> G["Server Zod validation"]
  G --> H["Normalize API errors"]
  H --> I["setError or root message"]
  G --> J["Persist or notify"]

3つ以上のユースケースで要件を切る

Claude Codeへの依頼は、フォームの種類ごとに変えます。すべてを同じ「問い合わせフォーム」と見なすと、必要な検証が抜けます。

ユースケース必須の検証落とし穴
SaaSの無料トライアル申込会社メール、利用人数、プラン、利用規約同意個人メールを許可するか、法人ドメインだけにするかを曖昧にする
問い合わせフォーム名前、メール、カテゴリ、本文、スパムっぽいURL数画面の検証だけでAPI側の再検証を忘れる
管理画面のユーザー編集権限、ロール、既存ID、更新対象の所有者UIで非表示にしたフィールドをAPIで拒否しない
予約・決済前フォーム日付、数量、電話番号、住所、在庫二重送信で同じ予約や請求を作る

Claude Codeへの最初の指示は、次のように具体化します。

問い合わせフォームを実装してください。
対象ファイルだけを変更し、React Hook Form、Zod、@hookform/resolvers/zodを使ってください。
クライアント検証とサーバー検証を同じschemaで行い、APIエラーは { ok: false, errors: [{ path, message }] } に正規化してください。
二重送信防止、aria-invalid、aria-describedby、role="alert"、i18n用message key、Vitest/Testing Libraryのテストを含めてください。
疑似コードではなく、TypeScript/Reactでそのまま貼れるコードにしてください。

ここで重要なのは、Claude Codeに「フォームを作って」ではなく「失敗の扱いまで作って」と伝えることです。レビュー時も同じ観点で見ます。値の型、サーバー境界、エラー表示、二重送信、アクセシビリティ、テストのどれかが抜けていれば、フォームとしては未完成です。

コピペできるZodスキーマ

次のスキーマは、問い合わせフォームを想定しています。schemaは入力の契約書です。z.inferはZodスキーマからTypeScript型を取り出す仕組みです。メッセージには日本語の文章ではなくmessage keyを入れています。こうすると、APIが返したエラーを日本語、英語などのUI側で翻訳できます。

// src/features/contact/contactSchema.ts
import { z } from "zod";

export const contactSchema = z
  .object({
    name: z
      .string()
      .trim()
      .min(1, "validation.name.required")
      .max(60, "validation.name.tooLong"),
    email: z
      .string()
      .trim()
      .min(1, "validation.email.required")
      .email("validation.email.invalid"),
    plan: z.enum(["starter", "team", "enterprise"], {
      message: "validation.plan.invalid",
    }),
    seats: z
      .number({ message: "validation.seats.number" })
      .int("validation.seats.integer")
      .min(1, "validation.seats.min")
      .max(200, "validation.seats.max"),
    message: z
      .string()
      .trim()
      .min(20, "validation.message.tooShort")
      .max(1000, "validation.message.tooLong"),
    locale: z.enum(["ja", "en"], {
      message: "validation.locale.invalid",
    }),
    agreeToTerms: z
      .boolean()
      .refine((value) => value === true, "validation.terms.required"),
  })
  .strict();

export type ContactFormData = z.infer<typeof contactSchema>;

export const defaultContactValues: ContactFormData = {
  name: "",
  email: "",
  plan: "starter",
  seats: 1,
  message: "",
  locale: "ja",
  agreeToTerms: false,
};

seatsnumberにしている点が実務で効きます。HTMLのinput type="number"は見た目が数字でも、登録方法を間違えると文字列として届きます。React Hook Form側でvalueAsNumber: trueを付けるか、API専用スキーマでz.coerce.number()を使うかを決めてください。どちらにしても、型ずれを放置しないことが大切です。

サーバーサイド検証とAPIエラー正規化

クライアント検証はユーザー体験のためです。サーバーサイド検証は安全性のためです。ブラウザのJavaScriptは無効化も改変もできます。curlや別のクライアントから不正なJSONを送ることもできます。だからAPIでは、request.json()の結果をunknownとして扱い、Zodを通るまで信用しません。

// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";

type FieldPath = keyof ContactFormData | "root";

export type ApiFieldError = {
  path: FieldPath;
  message: string;
};

export type ContactApiResponse =
  | { ok: true; id: string }
  | { ok: false; errors: ApiFieldError[] };

function normalizeZodError(error: z.ZodError): ApiFieldError[] {
  return error.issues.map((issue) => {
    const firstPath = issue.path[0];
    return {
      path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
      message: issue.message,
    };
  });
}

function jsonResponse(body: ContactApiResponse, status: number): Response {
  return Response.json(body, { status });
}

async function isBlockedDomain(email: string): Promise<boolean> {
  return email.toLowerCase().endsWith("@example.invalid");
}

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return jsonResponse(
      { ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] },
      400,
    );
  }

  const parsed = contactSchema.safeParse(body);
  if (!parsed.success) {
    return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
  }

  if (await isBlockedDomain(parsed.data.email)) {
    return jsonResponse(
      { ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] },
      409,
    );
  }

  // Replace this with database insert, CRM sync, or email notification.
  const id = crypto.randomUUID();
  return jsonResponse({ ok: true, id }, 201);
}

ここでの正規化は、画面側を単純にするための層です。Zodのissues、JSONパース失敗、法人ドメイン制限のような業務エラーが、すべてpathmessageの配列になります。Claude Codeにレビューさせるときは「APIの失敗形式が画面で処理できる単一形式になっているか」を必ず確認させます。

React Hook Formでアクセシブルに表示する

次はクライアントコンポーネントです。isSubmittingで二重送信を防ぎ、aria-invalidaria-describedbyでエラーの関連を支援技術へ伝えます。role="alert"はエラーが出たことを読み上げやすくするための属性です。

// src/features/contact/ContactForm.tsx
"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
  contactSchema,
  defaultContactValues,
  type ContactFormData,
} from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";

const messages = {
  ja: {
    "validation.name.required": "お名前を入力してください。",
    "validation.name.tooLong": "お名前は60文字以内で入力してください。",
    "validation.email.required": "メールアドレスを入力してください。",
    "validation.email.invalid": "有効なメールアドレスを入力してください。",
    "validation.email.blocked": "このメールドメインでは申し込めません。",
    "validation.plan.invalid": "プランを選択してください。",
    "validation.seats.number": "利用人数は数値で入力してください。",
    "validation.seats.integer": "利用人数は整数で入力してください。",
    "validation.seats.min": "利用人数は1人以上にしてください。",
    "validation.seats.max": "利用人数は200人以下にしてください。",
    "validation.message.tooShort": "相談内容は20文字以上で入力してください。",
    "validation.message.tooLong": "相談内容は1000文字以内で入力してください。",
    "validation.locale.invalid": "言語設定が不正です。",
    "validation.terms.required": "利用規約への同意が必要です。",
    "validation.json.invalid": "送信内容を読み取れませんでした。",
    "form.submitError": "送信に失敗しました。時間をおいて再度お試しください。",
  },
  en: {
    "validation.name.required": "Enter your name.",
    "validation.name.tooLong": "Name must be 60 characters or fewer.",
    "validation.email.required": "Enter your email address.",
    "validation.email.invalid": "Enter a valid email address.",
    "validation.email.blocked": "This email domain is not allowed.",
    "validation.plan.invalid": "Choose a plan.",
    "validation.seats.number": "Seats must be a number.",
    "validation.seats.integer": "Seats must be an integer.",
    "validation.seats.min": "Seats must be at least 1.",
    "validation.seats.max": "Seats must be 200 or fewer.",
    "validation.message.tooShort": "Message must be at least 20 characters.",
    "validation.message.tooLong": "Message must be 1000 characters or fewer.",
    "validation.locale.invalid": "Locale is invalid.",
    "validation.terms.required": "You must agree to the terms.",
    "validation.json.invalid": "The submitted body could not be read.",
    "form.submitError": "Submit failed. Please try again later.",
  },
} as const;

type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];

function t(locale: Locale, key: string): string {
  const table = messages[locale] as Record<string, string>;
  return table[key] ?? key;
}

function isFormField(path: ApiFieldError["path"]): path is FormField {
  return formFields.includes(path as FormField);
}

export function ContactForm({ locale = "ja" }: { locale?: Locale }) {
  const [serverMessage, setServerMessage] = useState<string | null>(null);
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(contactSchema),
    defaultValues: { ...defaultContactValues, locale },
    mode: "onBlur",
  });

  async function onValidSubmit(values: ContactFormData) {
    setServerMessage(null);

    const response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });

    const result = (await response.json()) as ContactApiResponse;
    if (!response.ok || !result.ok) {
      const apiErrors = result.ok ? [] : result.errors;
      for (const error of apiErrors) {
        if (isFormField(error.path)) {
          setError(error.path, { type: "server", message: t(locale, error.message) });
        } else {
          setServerMessage(t(locale, error.message));
        }
      }
      if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
      return;
    }

    reset({ ...defaultContactValues, locale });
  }

  return (
    <form onSubmit={handleSubmit(onValidSubmit)} noValidate>
      {serverMessage ? (
        <p role="alert" aria-live="assertive">
          {serverMessage}
        </p>
      ) : null}

      <div>
        <label htmlFor="contact-name">お名前</label>
        <input
          id="contact-name"
          autoComplete="name"
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? "contact-name-error" : undefined}
          {...register("name")}
        />
        {errors.name?.message ? (
          <p id="contact-name-error" role="alert">
            {t(locale, errors.name.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-email">メールアドレス</label>
        <input
          id="contact-email"
          type="email"
          autoComplete="email"
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? "contact-email-error" : undefined}
          {...register("email")}
        />
        {errors.email?.message ? (
          <p id="contact-email-error" role="alert">
            {t(locale, errors.email.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-plan">プラン</label>
        <select id="contact-plan" {...register("plan")}>
          <option value="starter">Starter</option>
          <option value="team">Team</option>
          <option value="enterprise">Enterprise</option>
        </select>
      </div>

      <div>
        <label htmlFor="contact-seats">利用人数</label>
        <input
          id="contact-seats"
          type="number"
          min={1}
          max={200}
          aria-invalid={Boolean(errors.seats)}
          aria-describedby={errors.seats ? "contact-seats-error" : undefined}
          {...register("seats", { valueAsNumber: true })}
        />
        {errors.seats?.message ? (
          <p id="contact-seats-error" role="alert">
            {t(locale, errors.seats.message)}
          </p>
        ) : null}
      </div>

      <div>
        <label htmlFor="contact-message">相談内容</label>
        <textarea
          id="contact-message"
          rows={6}
          aria-invalid={Boolean(errors.message)}
          aria-describedby={errors.message ? "contact-message-error" : undefined}
          {...register("message")}
        />
        {errors.message?.message ? (
          <p id="contact-message-error" role="alert">
            {t(locale, errors.message.message)}
          </p>
        ) : null}
      </div>

      <label>
        <input type="checkbox" {...register("agreeToTerms")} />
        利用規約に同意します
      </label>
      {errors.agreeToTerms?.message ? (
        <p role="alert">{t(locale, errors.agreeToTerms.message)}</p>
      ) : null}

      <button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
        {isSubmitting ? "送信中..." : "送信する"}
      </button>
    </form>
  );
}

UIの見た目はプロジェクトのデザインシステムに合わせてください。ただし、エラーの構造は変えない方が安全です。aria-describedbyがエラー文のidを指しているか、サーバーエラーがフィールドに戻っているか、送信中にボタンが無効化されるかを重点的に確認します。

テストで型ずれとエラー表示漏れを防ぐ

テストは大量に書くより、壊れると困る境界を押さえます。スキーマ、エラー正規化、画面表示、二重送信防止の4つです。

// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";

const validInput = {
  name: "Masa",
  email: "masa@example.com",
  plan: "team",
  seats: 3,
  message: "Claude Codeでフォームの検証を改善したいです。",
  locale: "ja",
  agreeToTerms: true,
} as const;

describe("contactSchema", () => {
  it("accepts valid input", () => {
    expect(contactSchema.safeParse(validInput).success).toBe(true);
  });

  it("rejects string seats", () => {
    const result = contactSchema.safeParse({ ...validInput, seats: "3" });
    expect(result.success).toBe(false);
  });
});

describe("ContactForm", () => {
  beforeEach(() => {
    vi.restoreAllMocks();
  });

  it("shows client validation errors and does not submit", async () => {
    const fetchMock = vi.spyOn(globalThis, "fetch");
    render(<ContactForm locale="ja" />);

    await userEvent.click(screen.getByRole("button", { name: "送信する" }));

    expect(await screen.findByText("お名前を入力してください。")).toBeInTheDocument();
    expect(fetchMock).not.toHaveBeenCalled();
  });

  it("maps API field errors back to the email field", async () => {
    vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
      new Response(
        JSON.stringify({
          ok: false,
          errors: [{ path: "email", message: "validation.email.blocked" }],
        }),
        { status: 409, headers: { "Content-Type": "application/json" } },
      ),
    );

    render(<ContactForm locale="ja" />);
    await userEvent.type(screen.getByLabelText("お名前"), "Masa");
    await userEvent.type(screen.getByLabelText("メールアドレス"), "masa@example.invalid");
    await userEvent.selectOptions(screen.getByLabelText("プラン"), "team");
    await userEvent.clear(screen.getByLabelText("利用人数"));
    await userEvent.type(screen.getByLabelText("利用人数"), "3");
    await userEvent.type(screen.getByLabelText("相談内容"), "フォームバリデーションについて相談したいです。");
    await userEvent.click(screen.getByRole("checkbox"));
    await userEvent.click(screen.getByRole("button", { name: "送信する" }));

    expect(await screen.findByText("このメールドメインでは申し込めません。")).toBeInTheDocument();
  });
});

このテストは「数字入力が文字列で通ってしまう」「APIのフィールドエラーが画面に出ない」「未入力でもfetchしてしまう」という失敗を防ぎます。テスト名をClaude Codeに読ませれば、修正時に何を守るべきかも伝わります。

ありがちな失敗例とレビュー観点

一つ目は、クライアントだけで検証して満足することです。zodResolverを入れても、API側でsafeParseしていなければ攻撃や不正クライアントに弱いままです。

二つ目は、二重送信です。isSubmittingでボタンを無効化しても、API側の冪等性が必要な処理もあります。予約や決済では、リクエストIDや一意制約も検討します。

三つ目は、型ずれです。input type="number"、チェックボックス、日付、selectの空値は特にずれやすいです。valueAsNumbersetValueAs、Zodのcoerceを使うかどうかを設計します。

四つ目は、エラー表示漏れです。Zodエラーは出るがサーバーの業務エラーは消える、rootエラーが表示されない、翻訳キーがそのまま画面に出る、という事故がよくあります。

五つ目は、アクセシビリティの後回しです。赤文字だけでは不十分です。ラベル、aria-invalidaria-describedbyrole="alert"、キーボード操作、フォーカス移動をレビュー対象にします。

六つ目は、Claude Codeの変更範囲を広げすぎることです。フォーム修正なのに認証、DB、デザインシステムまで同時に触るとレビュー不能になります。対象ファイル、禁止事項、テストコマンドを依頼文に入れてください。

マネタイズにつながる実装にする

問い合わせやトライアルのフォームは、単なるUI部品ではなく収益導線です。送信失敗、入力ストレス、確認漏れがあると、広告やSEOで集めた読者が離脱します。Claude Code Labの相談・トレーニングでは、既存フォームのバリデーション設計、Claude Codeへの依頼文、レビュー用チェックリスト、テスト追加までを実際のコードベースに合わせて整理できます。

自社サービス、SaaS、予約サイト、教育コンテンツ販売、B2Bの資料請求では、フォーム改善がそのままCVR改善につながります。特に「送信できない理由が分からない」「英語版だけエラー文が崩れる」「APIエラーが画面に戻らない」という状態は、先に直す価値があります。

この記事で紹介した内容を実際に試した結果

Masaの検証では、問い合わせフォームをこの構成に寄せると、Claude Codeのレビュー指示がかなり具体的になりました。最初はseatsが文字列でAPIに届く型ずれと、ブロックドメインのAPIエラーが画面に表示されない問題が出ましたが、valueAsNumber、APIエラー正規化、Testing Libraryの回帰テストを追加したことで再発を防げました。特に効果があったのは、エラーメッセージを文章ではなくmessage keyで返す設計です。日本語UIと英語UIの両方で同じAPIレスポンスを使えるため、翻訳漏れの確認が簡単になりました。

まとめ

Claude Codeでフォームバリデーションを実装するなら、React Hook Form、Zod、サーバーサイド検証、APIエラー正規化、アクセシビリティ、i18n、テストをセットで扱います。フォームは小さく見えますが、ユーザー入力がシステムへ入る境界です。Claude Codeには「見た目」ではなく「失敗時のふるまい」まで依頼してください。そうすれば、実装速度だけでなく、公開後の問い合わせ品質と運用の安定性も上げられます。

#Claude Code #フォームバリデーション #フォーム #Zod #React
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。