用 Claude Code 实现表单验证:React Hook Form 与 Zod 指南
用 Claude Code 实现 React Hook Form、Zod、服务端验证、错误归一化、i18n、可访问性与测试。
先定义验证边界,再让 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 Code 和 Zod validation with Claude Code。实现前建议核对官方资料:Claude Code overview、React Hook Form useForm、React Hook Form Resolvers、Zod、Next.js Route Handlers 和 Testing 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 解析,还是“禁止某个邮箱域名”的业务规则,前端都处理 path 和 message。
React Hook Form 中的可访问错误显示
下面的组件用 isSubmitting 防止重复提交,用 aria-invalid 和 aria-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 生成的代码越可靠。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。