用 Claude Code 安全实现 React Hook Form 表单
从 useForm、Zod 校验、错误提示、提交状态到测试,讲解如何用 Claude Code 构建可靠表单。
先定义表单契约,再让 Claude Code 动手
React Hook Form 是 React 中常用的表单库。它不要求你把每一次键盘输入都塞进组件 state,而是利用浏览器原生表单能力,再通过 register、handleSubmit、formState 管理字段、提交和错误。对初学者来说,这能把几个关键问题讲清楚:值从哪里来,什么时候校验,提交中如何防止重复点击。
Claude Code 可以一次生成组件、Zod schema、API route、测试和重构步骤。但表单通常连接着真实业务:咨询预约、产品试用、购买前调查、邮件订阅、客户资料更新。只说“帮我做一个表单”,很容易得到一个看起来不错、却缺少无障碍错误提示、服务端校验或提交中状态的实现。
本文用咨询表单做例子,说明如何使用 useForm、zodResolver、字段错误、提交状态、服务端二次校验、测试,以及更安全的 Claude Code 提示词。React 组件整体思路可以参考Claude Code React 开发,Zod 规则设计可以继续看Claude Code Zod 校验。
架构总览:把 schema 放在中心
React Hook Form 负责表单流程。Zod 负责描述什么输入是有效的。@hookform/resolvers/zod 提供的 zodResolver 则把两者接起来,让 React Hook Form 在校验时执行 Zod schema。
flowchart TD
A["用户输入"] --> B["React Hook Form register"]
B --> C["zodResolver 执行 schema 校验"]
C --> D{"输入有效吗"}
D -->|否| E["显示字段错误"]
D -->|是| F["handleSubmit 提交数据"]
F --> G["API 复用同一个 schema 校验"]
G --> H["保存、通知或同步到 CRM"]
简单说,useForm 是表单控制器,Zod schema 是规则表,resolver 是连接器。让 Claude Code 修改表单时,明确这三部分能减少误改。之后要新增字段或选项,也可以要求它同时更新 schema、组件、API 和测试,而不是只改界面。
核对细节时建议看官方资料:React Hook Form 的useForm 文档、React Hook Form Resolvers、Zod API 文档、React 的<input> 参考,以及 Claude Code 的overview和commands。
可复制的 Zod schema
先把校验规则放到独立文件里。下面的例子包含姓名、邮箱、分类、正文和联系同意。z.infer 会从 schema 推导 TypeScript 类型,避免“类型定义一份、运行时校验又一份”的双重维护。
// src/features/inquiry/inquirySchema.ts
import { z } from "zod";
export const inquirySchema = z.object({
name: z
.string()
.trim()
.min(1, "请输入姓名")
.max(80, "姓名请控制在80个字符以内"),
email: z
.string()
.trim()
.email("请输入有效的邮箱地址"),
category: z.enum(["consulting", "support", "billing"], {
error: "请选择咨询分类",
}),
message: z
.string()
.trim()
.min(10, "正文至少输入10个字符")
.max(1000, "正文请控制在1000个字符以内"),
agreeToContact: z.boolean().refine((value) => value, {
message: "必须同意我们为回复而联系你",
}),
});
export type InquiryFormValues = z.infer<typeof inquirySchema>;
category 使用 z.enum,是为了限制提交值只能是固定集合。实际项目中,这些值可能决定线索分配、邮件模板、客服队列或 CRM 字段。提示 Claude Code 时,最好同时写清“显示文案”和“提交值”,例如“显示为技术支持,提交值是 support”。界面可以本地化,数据值要稳定。
用 useForm 处理输入、错误和提交状态
下面是表单组件。mode: "onBlur" 表示用户离开输入框时进行校验。对面向普通用户的咨询表单来说,这比每输入一个字符就显示红字更友好。真正提交时,handleSubmit 仍会做最终校验。
// src/features/inquiry/InquiryForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { inquirySchema, type InquiryFormValues } from "./inquirySchema";
async function sendInquiry(values: InquiryFormValues) {
const response = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) {
throw new Error("Failed to send inquiry");
}
}
export function InquiryForm() {
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<InquiryFormValues>({
resolver: zodResolver(inquirySchema),
mode: "onBlur",
defaultValues: {
name: "",
email: "",
message: "",
agreeToContact: false,
},
});
const onSubmit = async (values: InquiryFormValues) => {
try {
await sendInquiry(values);
reset();
} catch {
setError("root", {
type: "server",
message: "发送失败,请稍后再试。",
});
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">姓名</label>
<input
id="name"
autoComplete="name"
aria-invalid={errors.name ? "true" : "false"}
aria-describedby={errors.name ? "name-error" : undefined}
{...register("name")}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
autoComplete="email"
aria-invalid={errors.email ? "true" : "false"}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="category">咨询内容</label>
<select
id="category"
aria-invalid={errors.category ? "true" : "false"}
aria-describedby={errors.category ? "category-error" : undefined}
{...register("category")}
>
<option value="">请选择</option>
<option value="consulting">导入咨询</option>
<option value="support">技术支持</option>
<option value="billing">账单或合同</option>
</select>
{errors.category && (
<p id="category-error" role="alert">
{errors.category.message}
</p>
)}
</div>
<div>
<label htmlFor="message">正文</label>
<textarea
id="message"
rows={6}
aria-invalid={errors.message ? "true" : "false"}
aria-describedby={errors.message ? "message-error" : undefined}
{...register("message")}
/>
{errors.message && (
<p id="message-error" role="alert">
{errors.message.message}
</p>
)}
</div>
<label>
<input type="checkbox" {...register("agreeToContact")} />
我同意你们为回复本次咨询而联系我
</label>
{errors.agreeToContact && (
<p role="alert">{errors.agreeToContact.message}</p>
)}
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "发送中..." : "发送咨询"}
</button>
</form>
);
}
这里的关键不是样式,而是错误提示的位置和可访问性。每个字段都有 aria-invalid,错误文本有 role="alert",输入框用 aria-describedby 指向错误。这样既方便屏幕阅读器,也方便测试定位。更多界面可访问性可以参考Claude Code 可访问性实现。
服务端也要复用同一个 schema
前端校验改善体验,但不能作为安全边界。用户可以绕过浏览器表单直接调用 API。因此 API 端也必须复用 schema,先检查 payload,再执行保存、邮件通知或 CRM 同步。
// app/api/inquiry/route.ts
import { NextResponse } from "next/server";
import { inquirySchema } from "@/features/inquiry/inquirySchema";
export async function POST(request: Request) {
const payload = await request.json().catch(() => null);
const parsed = inquirySchema.safeParse(payload);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid inquiry",
fields: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
// TODO: 在这里保存到数据库、发送邮件或同步 CRM。
return NextResponse.json({ ok: true });
}
让 Claude Code 增加 API 时,可以明确写:“复用 inquirySchema,校验失败返回 400 和字段错误,正式邮件发送和 CRM 集成先保留 TODO。”这样第一版更容易审查,密钥、重试、重复提交处理可以作为独立任务。
测试表单行为
表单的错误很容易被肉眼忽略。至少要测试空提交、有效提交、服务端失败和提交按钮禁用。下面用 Vitest 与 React Testing Library 验证“空提交显示错误”和“合法输入会调用 fetch”。
// src/features/inquiry/InquiryForm.test.tsx
import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { InquiryForm } from "./InquiryForm";
afterEach(() => {
vi.unstubAllGlobals();
});
test("空提交时显示校验错误", async () => {
render(<InquiryForm />);
await userEvent.click(screen.getByRole("button", { name: "发送咨询" }));
expect(await screen.findAllByRole("alert")).toHaveLength(5);
});
test("输入有效时提交到API", async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
render(<InquiryForm />);
await userEvent.type(screen.getByLabelText("姓名"), "Masa");
await userEvent.type(screen.getByLabelText("邮箱"), "masa@example.com");
await userEvent.selectOptions(screen.getByLabelText("咨询内容"), "consulting");
await userEvent.type(
screen.getByLabelText("正文"),
"我想安全地导入 React Hook Form。",
);
await userEvent.click(
screen.getByLabelText("我同意你们为回复本次咨询而联系我"),
);
await userEvent.click(screen.getByRole("button", { name: "发送咨询" }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/inquiry",
expect.objectContaining({ method: "POST" }),
);
});
你可以要求 Claude Code 先写失败测试,再补实现。端到端流程可参考Claude Code Playwright 测试。如果要追踪业务效果,成功提交后再触发 analytics,而不是仅统计按钮点击;相关思路可看Claude Code 分析埋点。
给 Claude Code 的安全提示词
好的表单提示词要包含范围、约束、测试命令和不要做的事。可以从下面这个模板开始:
请用 React Hook Form 和 Zod 实现咨询表单。
范围:
- 只修改 src/features/inquiry 和 app/api/inquiry
- 使用 useForm、zodResolver,并从 schema 推导 TypeScript 类型
- 字段包括 name, email, category, message, agreeToContact
- 字段错误使用 role="alert",并设置 aria-describedby
- isSubmitting 为 true 时禁用提交按钮
- API route 复用同一个 Zod schema 做 safeParse
- 添加 Vitest + Testing Library 测试
验证:
- npm test -- InquiryForm
- npm run typecheck
不要做:
- 不新增 UI 库
- 不重命名已有 category 值
- 不在本任务里实现正式邮件、CRM 或密钥处理
修改需求也要具体。不要只说“加一个分类”,而要说“新增显示文案 培训咨询,提交值 training,同时更新 schema enum、select、API 校验、测试和分析映射”。Claude Code 会搜索相关文件,但契约仍然应该由人来定义。
典型用途和设计差异
| 用途 | 推荐结构 | 注意点 |
|---|---|---|
| 咨询表单 | Zod + React Hook Form + API 二次校验 | 统计成功线索,不要只统计按钮点击 |
| 个人资料编辑 | 用 defaultValues 放入已有数据 | 保存后 reset(savedValues) 清掉 dirty 状态 |
| 购买前问卷 | select、radio、checkbox 组合 | 提交值要和商品或 CRM ID 对齐 |
| 管理后台搜索 | 轻量校验并同步 URL query | 避免每个按键都请求 API |
共同原则是:界面文案和提交值分开。文案可以翻译,可以改写;提交值必须稳定,因为报表、自动化和后端代码都依赖它。让 Claude Code 生成表单前,先给它一张“显示文案 / 提交值”的小表。
常见失败
第一,只在前端校验。前端校验是体验,不是安全。API route 也要 import 同一个 schema,并在处理数据前调用 safeParse。
第二,isSubmitting 很快恢复。原因通常是 onSubmit 没有 await 异步请求。fetch、数据库、邮件发送都要返回或等待 Promise,失败时用 setError("root", ...) 展示。
第三,错误提示离字段太远。只在顶部显示“有错误”不够。每个字段下方要有明确错误,顶部摘要只能作为补充。
第四,让 Claude Code 随手引入新的 UI 体系。如果项目已有 TextField、Select、Button、toast,就在提示词里要求复用。表单任务里新增 UI 库会放大审查范围。
第五,忘记提交后的路径。咨询表单需要成功提示、感谢页、邮件通知、analytics 事件,有时还要 CRM 同步。把这些作为明确任务,避免表单看起来完成但业务没有闭环。
收益导线中的表单
表单质量不只看外观。真正要看的是它支持什么漏斗:免费 PDF 注册、商品线索、付费模板购买、导入咨询。让 Claude Code 重构前,先定义业务事件,再决定减少字段、改错误文案还是增加测试。
如果你想系统化学习,可以查看教材与产品;如果要把 Claude Code 表单改造用于团队流程,可以从培训与咨询开始。表单只是小组件,却经常是内容和收入之间的关键入口。
实际测试结果
Masa 在一个小型咨询流程中试了这个结构。最有效的改动是把 schema 集中到一个文件,因为它避免了“UI 增加了选项,API 允许值却忘记更新”的错误。第二个有效点是空提交和有效提交两条测试。后续让 Claude Code 修改表单时,测试很快发现了错误提示遗漏和 fetch 调用损坏。实际维护中,把表单当成“输入契约”,比只把它当成 UI 组件更稳。
免费 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 与咨询路径都要可审查。