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 写代码

表单验证不只是“必填项为空时显示红字”。在真实产品里,表单经常连接试用申请、销售线索、预约、付款前信息、用户资料和后台管理。一个表单如果看起来完整,却没有服务端验证、没有防止重复提交、没有把 API 错误显示回对应字段,就会把问题留给销售、客服或数据库清理。

Claude Code 很适合处理这种结构化任务,但提示词必须清楚。React Hook Form 负责表单状态和提交,Zod 负责 schema,也就是数据契约。resolver 是连接 React Hook Form 和 Zod 的适配器。服务端验证是指 API 不信任浏览器传来的 JSON,重新用同一套规则检查。错误归一化是把 Zod 错误、业务错误、JSON 解析失败整理成同一种返回格式。i18n 是国际化,可访问性是让键盘用户和读屏软件也能理解错误。

本文用一个 B2B 联系表单说明如何让 Claude Code 实现 React Hook Form、Zod、服务端验证、API 错误归一化、可访问性、多语言消息和测试。相关站内文章可以继续看 React Hook Form with Claude CodeZod validation with Claude Code。实现前建议核对官方资料:Claude Code overviewReact Hook Form useFormReact Hook Form ResolversZodNext.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"]

三类以上场景要分开设计

不要只对 Claude Code 说“帮我加表单验证”。不同业务表单需要不同规则。

场景关键验证常见坑
SaaS 试用申请工作邮箱、团队人数、套餐、同意条款没说清是否允许 Gmail 等个人邮箱
联系表单姓名、邮箱、咨询类型、正文长度、垃圾链接数量只做浏览器验证,API 直接信任请求体
后台用户编辑权限、角色、不可变 ID、字段白名单UI 隐藏字段,但 API 仍接受攻击者传来的字段
预约或付款前表单日期、数量、电话、地址、库存双击提交后创建重复预约或订单

给 Claude Code 的提示词可以这样写:

Implement a contact form.
Only change the files in this feature.
Use React Hook Form, Zod, and @hookform/resolvers/zod.
Validate on the client and again in the API using the same schema.
Normalize API failures as { ok: false, errors: [{ path, message }] }.
Include duplicate-submit prevention, aria-invalid, aria-describedby, role="alert",
i18n message keys, and Vitest/Testing Library tests.
Use copy-pasteable TypeScript and React, not pseudocode.

这个提示词的重点是失败路径。评审时要看值的类型、服务端边界、错误显示、重复提交、可访问性和测试是否都存在。

可直接复制的 Zod schema

下面的 schema 使用 message key,而不是把中文错误文案写死在服务端。这样 API 可以返回同一份错误,前端再根据语言表翻译。

// 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: "en",
  agreeToTerms: false,
};

seats 明确是 number。HTML 的数字输入如果接线错误,仍可能进入代码时变成字符串。可以在 React Hook Form 里使用 valueAsNumber,也可以为 API 单独准备 z.coerce.number()。关键是不要让类型漂移悄悄通过。

服务端验证与 API 错误归一化

浏览器验证只是用户体验。安全边界在 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);
}

归一化的好处是 UI 很简单。无论错误来自 Zod、JSON 解析,还是“禁止某个邮箱域名”的业务规则,前端都处理 pathmessage

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 = "en" }: { 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">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">Email address</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">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">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">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")} />
        I agree to the terms
      </label>
      {errors.agreeToTerms?.message ? <p role="alert">{t(locale, errors.agreeToTerms.message)}</p> : null}

      <button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
        {isSubmitting ? "Submitting..." : "Submit"}
      </button>
    </form>
  );
}

实际项目可以把 label 也放进语言字典。这里保留英文标签,是为了让测试代码更短、更容易复制。

测试重点:类型漂移和错误显示

// 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: "I want to improve validation in a Claude Code workflow.",
  locale: "en",
  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="en" />);

    await userEvent.click(screen.getByRole("button", { name: "Submit" }));

    expect(await screen.findByText("Enter your name.")).toBeInTheDocument();
    expect(fetchMock).not.toHaveBeenCalled();
  });
});

这段测试能防止三个常见问题:未填写时仍然 fetch、数字字段变成字符串、错误文案没有出现在画面上。API 字段错误的映射也建议再加一条测试。

常见失败例

第一,只在客户端验证。zodResolver 不能替代 API 的 safeParse。第二,重复提交。按钮 disabled 有帮助,但预约和支付还需要 idempotency key 或数据库唯一约束。第三,类型漂移。数字、checkbox、日期、空 select 最容易出问题。第四,错误显示漏掉。Zod 错误显示了,但业务错误或 root 错误消失。第五,可访问性最后才补。红字不是完整的错误体验。第六,让 Claude Code 改动范围过大。表单任务不应该顺手重写认证、DB 或设计系统。

变现和实际效果

表单是转化路径。试用申请表坏掉,会浪费广告费和 SEO 流量。联系表单难用,会增加销售跟进成本。ClaudeCodeLab 的 Claude Code 培训与咨询 可以帮助团队把验证 schema、提示词、测试和代码评审清单整理成可重复流程。

Masa 的实际验证中,第一版 Claude Code 代码界面不错,但 seats 作为字符串进入 API,禁止域名的错误也没有回到 email 字段。加入 valueAsNumber、统一 API 错误格式和 Testing Library 回归测试后,这两个问题都能稳定复现并修复。message key 的设计也让日文和英文 UI 可以共用同一个 API 响应。

总结

用 Claude Code 做表单验证时,不要只生成 UI。把 React Hook Form、Zod、服务端验证、错误归一化、可访问性、i18n 和测试放在一个任务里处理。表单是外部输入进入系统的边界,边界越清楚,Claude Code 生成的代码越可靠。

#Claude Code #validation #forms #Zod #React
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。