React Hook Form + Zod
pnpm add react-hook-form @hookform/resolvers zod
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const schema = z.object({ email: z.string().email("邮箱格式不对"), password: z.string().min(8, "至少 8 位"), }); type FormData = z.infer<typeof schema>; export function SignInForm() { const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm<FormData>({ resolver: zodResolver(schema), }); const onSubmit = async (data: FormData) => { await signIn(data); // data 已经通过 Zod 校验,类型安全 }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register("email")} placeholder="邮箱" /> {errors.email && <p>{errors.email.message}</p>} <input {...register("password")} type="password" placeholder="密码" /> {errors.password && <p>{errors.password.message}</p>} <button disabled={isSubmitting}>登录</button> </form> ); }
10 行模板 + schema——所有校验、错误展示、类型推断都自动打通。
嵌套字段
const schema = z.object({ user: z.object({ name: z.string().min(1, "姓名必填"), age: z.coerce.number().int().min(0), }), address: z.object({ city: z.string(), zip: z.string().regex(/^\d{6}$/), }), }); // register 用 dot path <input {...register("user.name")} /> <input {...register("user.age")} type="number" /> <input {...register("address.city")} /> // errors 也是嵌套 errors.user?.name?.message errors.address?.zip?.message
处理 coerce(string → number)
// HTML input value 永远是 string —— 用 coerce 处理 const schema = z.object({ age: z.coerce.number().int().min(18, "未满 18 岁不能注册"), price: z.coerce.number().nonnegative(), }); <input {...register("age")} type="number" /> // 输入 "30" → Zod 转 30 → onSubmit 拿到的 data.age 是 number
Zod 3.x 要用 valueAsNumber 辅助,v4 用 z.coerce 直接解决。
数组字段(动态添加)
const schema = z.object({ tags: z.array(z.string().min(1, "标签不能为空")).min(1, "至少 1 个标签"), }); const { register, control, handleSubmit } = useForm<T>({ resolver: zodResolver(schema) }); const { fields, append, remove } = useFieldArray({ control, name: "tags" }); {fields.map((f, i) => ( <div key={f.id}> <input {...register(`tags.${i}`)} /> <button onClick={() => remove(i)}>-</button> </div> ))} <button onClick={() => append("")}>+</button>
跨字段校验
const schema = z .object({ password: z.string().min(8), confirm: z.string(), }) .refine((d) => d.password === d.confirm, { message: "两次密码不一致", path: ["confirm"], }); // 错误会出现在 errors.confirm
default values
useForm<T>({ resolver: zodResolver(schema), defaultValues: { name: "", age: 18, tags: [""], }, });
RHF 的 defaultValues 和 Zod 的 .default(...) 不是一回事——前者是 UI 初始值,后者是解析时的默认值。写 form 建议两个都用。
异步校验(邮箱查重)
const schema = z.object({ email: z.string().email().refine( async (e) => !(await isEmailTaken(e)), "邮箱已被注册" ), }); useForm<T>({ resolver: zodResolver(schema), mode: "onBlur", // ← 改成 onBlur 避免每次敲键都查 DB });
异步 refine 在 RHF 里完全透明——zodResolver 会自动用 parseAsync。但注意把 mode 设 onBlur 或 onTouched,不然每次 onChange 都触发异步请求。
Next.js Server Actions + Conform
pnpm add @conform-to/react @conform-to/zod zod
// schemas/signup.ts —— 前后端共用 export const SignUpSchema = z.object({ email: z.string().email(), password: z.string().min(8), });
// app/signup/actions.ts —— Server Action "use server"; import { parseWithZod } from "@conform-to/zod"; import { SignUpSchema } from "@/schemas/signup"; export async function signUp(_: unknown, formData: FormData) { const submission = parseWithZod(formData, { schema: SignUpSchema }); if (submission.status !== "success") { return submission.reply(); } await createUser(submission.value); redirect("/dashboard"); }
// app/signup/page.tsx —— Client 表单 "use client"; import { useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod"; import { useActionState } from "react"; import { SignUpSchema } from "@/schemas/signup"; import { signUp } from "./actions"; export default function Page() { const [lastResult, action] = useActionState(signUp, null); const [form, fields] = useForm({ lastResult, onValidate: ({ formData }) => parseWithZod(formData, { schema: SignUpSchema }), shouldValidate: "onBlur", }); return ( <form id={form.id} onSubmit={form.onSubmit} action={action}> <input name={fields.email.name} /> <p>{fields.email.errors}</p> <input name={fields.password.name} type="password" /> <p>{fields.password.errors}</p> <button>注册</button> </form> ); }
Conform 的杀手锏:同一个 schema 被客户端 onValidate 和服务端 Action 两次执行——客户端提前拦错,服务端兜底防绕过。Zod schema 就是这个"单一真源"。
TanStack Form
pnpm add @tanstack/react-form zod
import { useForm } from "@tanstack/react-form"; import { z } from "zod"; const schema = z.object({ name: z.string().min(1), email: z.string().email(), }); function Form() { const form = useForm({ defaultValues: { name: "", email: "" }, validators: { onChange: schema }, // Zod schema 直接喂进去 onSubmit: async ({ value }) => { await submit(value); }, }); return ( <form.Field name="name"> {(field) => ( <> <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} /> {field.state.meta.errors.map((e) => <p>{e}</p>)} </> )} </form.Field> ); }
TanStack Form 2026 开始支持直接吃 Standard Schema(Zod / Valibot 都行)——不用 resolver 中间层。
三个库怎么选
| React Hook Form | Conform | TanStack Form | |
|---|---|---|---|
| 使用场景 | CSR 表单 | Next.js Server Actions | CSR + 强类型派 |
| Zod 集成 | zodResolver | parseWithZod | 直接 validators |
| 渐进增强 | 需要 JS | ✓ 原生 form POST | 需要 JS |
| Bundle | ~25KB | ~15KB | ~14KB |
| 学习成本 | 低 | 中(Server Action) | 中(types 复杂) |
决策
纯前端项目 → React Hook Form(生态最好);Next.js App Router → Conform(原生 FormData + 服务端兜底);超大 TS 项目或 React Native → TanStack Form。
纯前端项目 → React Hook Form(生态最好);Next.js App Router → Conform(原生 FormData + 服务端兜底);超大 TS 项目或 React Native → TanStack Form。
常见坑
1. 数字字段变成字符串
// ❌ z.number(); // 输入 "30" 会报错 Expected number // ✓ z.coerce.number(); // RHF: <input type="number" {...register("age", { valueAsNumber: true })} />
2. Checkbox 的 on/undefined 问题
// HTML checkbox unchecked 时 FormData 里就没这个 key const schema = z.object({ agree: z.boolean().refine((v) => v, "必须同意条款"), }); // RHF 里没问题(默认 false); // Conform / FormData 要用 z.coerce.boolean() 或 z.literal("on").transform(() => true)
3. Select 空值
// <option value="">请选择</option> 会变成空串 const schema = z.object({ role: z.enum(["admin", "user"], { message: "请选择角色" }), // 空串会命中 invalid_enum_value → 自定义错误里写"请选择角色" });
4. File input
const schema = z.object({ avatar: z .instanceof(File) .refine((f) => f.size < 5 * 1024 * 1024, "文件超过 5MB") .refine((f) => ["image/png", "image/jpeg"].includes(f.type), "只接受 PNG/JPG"), });
完整例子:注册表单
const SignUpSchema = z .object({ email: z.string().email("邮箱格式不对"), password: z.string().min(8, "至少 8 位").regex(/[A-Z]/, "需要大写字母"), confirm: z.string(), age: z.coerce.number().int().min(18, "未满 18 岁"), agree: z.literal(true, { errorMap: () => ({ message: "必须同意协议" }) }), }) .refine((d) => d.password === d.confirm, { message: "两次密码不一致", path: ["confirm"], }); export function SignUpForm() { const { register, handleSubmit, formState: { errors } } = useForm< z.infer<typeof SignUpSchema> >({ resolver: zodResolver(SignUpSchema), }); return ( <form onSubmit={handleSubmit(signUp)}> {/* fields... */} </form> ); }
本章小结
- RHF + zodResolver = React 表单事实标准
- HTML 输入都是 string,用
z.coerce或 RHF 的valueAsNumber - Next.js 用 Conform + Server Actions,一份 schema 前后端双重校验
- TanStack Form 2026 直接吃 Standard Schema,无需 resolver
- 异步 refine 用 mode=onBlur 避免每次敲键都请求