React 表单最佳实践
表单是前端最繁琐的活——验证、错误提示、动态字段、提交。本文讲清 React 表单的完整实践:React Hook Form vs Formik、zod 校验、受控 vs 非受控、Server Actions 提交、以及复杂数据(数组/嵌套)怎么处理,让你的表单既简洁又健壮。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
一、表单为什么这么难
写过两年业务的前端都有一个共识:页面切得再漂亮,表单一出问题全是事故。
表单的难度不在"输入框 + 提交按钮",而在于它把前端几乎所有麻烦事拧到了一起:
- 校验:前端校验、后端校验,还要保持一致;
- 错误提示:每个字段怎么错、错了说什么、什么时候显示;
- 动态字段:根据某个值显隐其他字段、数组增删、联动;
- 提交:loading、disabled、失败回滚、重复提交、网络中断;
- 复用:同一份逻辑要在新增、编辑、多步表单里复用。
更要命的是,React 没有官方表单方案。<input value={x} onChange={...} /> 这种受控写法看起来天经地义,但一旦字段超过 10 个,每次按键都会触发整棵表单重渲染,输入卡顿肉眼可见。
所以处理表单的第一原则不是"用什么库",而是:选对工具,把表单的心智负担减一半。下面我按选型、校验、状态、提交、错误、复杂场景、复用这条线,把实战讲透。
二、选型:React Hook Form vs Formik
这两者是社区主流,但它们的设计哲学完全不同。
Formik 是受控路线的代表。每个字段通过 handleChange / values 绑定,值存在 React state 里。优点是模型简单、调试直观;缺点是每键一次 setState,字段一多就卡,需要手动 memo 或者拆组件优化。
React Hook Form(RHF) 走非受控路线。用 register 把 ref 注册到原生 <input>,数据不进 React state,提交时一次性读取。性能几乎是 Formik 的数倍,且默认 reValidateMode 体验更顺手。
// React Hook Form 的最小例子
import { useForm } from "react-hook-form";
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: "邮箱必填" })} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">登录</button>
</form>
);
}
我的建议很直接:
- 新项目一律用 React Hook Form,生态、性能、TS 体验全面领先;
- Formik 只在维护老项目、或者团队已经重度依赖它的上下文里继续用;
- 别再自己手撸"受控 + useState"的表单库了,这是重复造一个残缺的轮子。
下面所有示例默认用 RHF。
三、校验:一份 zod schema,类型和校验都给你
表单校验最大的坑是"前后端各写一遍"。TypeScript 的类型只解决"形状",解决不了"邮箱格式、长度范围、必填"。引入 zod,一份 schema 同时产出 TS 类型和运行时校验,再通过 @hookform/resolvers/zod 无缝接入 RHF。
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().min(1, "邮箱必填").email("邮箱格式不正确"),
password: z.string().min(8, "密码至少 8 位"),
age: z.number({ invalid_type_error: "请输入数字" }).min(18, "需年满 18 岁"),
});
type FormValues = z.infer<typeof schema>; // 类型自动推导,无需手写 interface
function RegisterForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormValues) => {
// data 已经是校验通过的类型安全数据
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register("email")} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register("password")} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<input type="number" {...register("age", { valueAsNumber: true })} />
{errors.age && <span role="alert">{errors.age.message}</span>}
<button type="submit">注册</button>
</form>
);
}
要点:
- schema 是唯一事实来源。前端表单、Server Action、后端 API 共用同一份 zod schema,改一处,三处同步;
z.infer<typeof schema>反推类型,杜绝"类型跟校验不一致"的低级 bug;- 数字、日期类字段记得用
valueAsNumber/valueAsDate,否则拿到的是字符串。
四、受控 vs 非受控:别一上来就 setState
新手最常踩的坑:为了拿输入值,把每个字段都写成 const [email, setEmail] = useState("")。十个字段十个 useState,渲染爆炸。
非受控(RHF 的默认方式):通过 register 把原生 DOM ref 拿过来,值存在 DOM 里,React 不参与每次按键的重渲染。提交时 RHF 统一读取。这是大型表单不卡的关键。
受控(useState):适合真正需要"实时根据输入联动"的场景,比如输入时实时搜索、根据输入长度显示进度条。即便如此,RHF 也提供了 watch 和 useWatch 来按需订阅,不必退回 useState:
const password = watch("password");
const strength = getPasswordStrength(password); // 按需订阅,只在该字段变化时重渲染
实战原则:
- 默认非受控,RHF 全权托管;
- 单字段实时联动用
watch/useWatch,别滥用; - 整个表单状态外置到全局 store(Redux/Zustand)几乎总是错的——表单就该活在表单组件里。
五、提交:Server Actions + FormData 才是正道
Next.js App Router 时代,提交表单的最佳方式是 Server Actions + useFormState / useFormStatus,它天然支持渐进增强——即使 JS 没加载,原生 <form action> 也能提交。
// app/actions/register.ts
"use server";
import { schema } from "@/lib/schemas";
export async function registerAction(prevState: any, formData: FormData) {
const raw = Object.fromEntries(formData.entries());
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
try {
await createUser(parsed.data);
return { success: true };
} catch (e) {
return { error: "注册失败,请稍后重试" };
}
}
// app/register/page.tsx
import { useFormState, useFormStatus } from "react-dom";
import { registerAction } from "@/app/actions/register";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "提交中..." : "注册"}</button>;
}
export default function Page() {
const [state, formAction] = useFormState(registerAction, null);
return (
<form action={formAction}>
<input name="email" type="email" />
{state?.errors?.email?.[0] && <p>{state.errors.email[0]}</p>}
<SubmitButton />
</form>
);
}
如果是普通 SPA、或者用 API 路由,那就 handleSubmit 里 fetch,逻辑类似,但 loading / error 状态要自己用 isSubmitting 管:
const { formState: { isSubmitting }, setError } = useForm();
const onSubmit = async (data: FormValues) => {
try {
await fetch("/api/register", { method: "POST", body: JSON.stringify(data) });
} catch (e) {
setError("root", { message: "网络异常,请重试" }); // 表单级错误
}
};
要点:
- RHF 的
handleSubmit会把<button disabled={isSubmitting}>状态直接给你,不用自己造; - 同一份 zod schema 在 Server Action 里再
safeParse一次,前端校验是体验,后端校验是安全,两道闸缺一不可。
六、错误提示:字段级、表单级、可访问性
错误提示看似简单,做错了既难用又不可访问。
字段级错误:绑在每个输入下方,聚焦或失焦后立刻显示:
<input
{...register("email")}
aria-invalid={errors.email ? "true" : "false"}
/>
{errors.email && (
<span role="alert" id="email-error">{errors.email.message}</span>
)}
表单级错误:提交后服务端返回的失败(比如"账号已存在""验证码失效"),统一放在表单顶部,用一个显眼的提示区:
{errors.root && <div role="alert">{errors.root.message}</div>}
可访问性要点(很多人忽略):
- 给输入框加
aria-invalid,屏幕阅读器会读"无效"; - 错误信息用
role="alert",保证读屏时立即播报; - 尽量用
aria-describedby关联错误和输入,而不是只靠视觉位置。
这些细节在合同里没人在意,但用键盘和无障碍工具一测就现形——专业前端和糊弄事的差别就在这。
七、复杂表单:数组、嵌套、条件字段
业务表单从来不是平的。订单有多个商品项、地址有省市区联动、合同有可增减的条款——这些都要靠 RHF 的 useFieldArray 和嵌套 register。
import { useFieldArray } from "react-hook-form";
function OrderForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: "", qty: 1 }] },
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, i) => (
<div key={field.id}>
<input {...register(`items.${i}.name` as const, { required: true })} />
<input type="number" {...register(`items.${i}.qty` as const, { valueAsNumber: true })} />
<button type="button" onClick={() => remove(i)}>删除</button>
</div>
))}
<button type="button" onClick={() => append({ name: "", qty: 1 })}>加一项</button>
</form>
);
}
几个关键点:
useFieldArray必须给一个稳定key={field.id},绝不用数组下标当 key——否则删除中间项时 React 会错乱;- 嵌套字段名用路径字符串
items.${i}.name,TS 那边as const保住字面量类型; - 嵌套对象同理,
register("address.city")直接走; - 条件字段:显隐用
watch,校验用 zod 的superRefine联动判断——"选了'其他'就必须填备注"这种规则写在 schema 里最干净:tsx const schema = z.object({ source: z.string(), other: z.string().optional(), }).superRefine((data, ctx) => { if (data.source === "other" && !data.other) { ctx.addIssue({ code: "custom", path: ["other"], message: "请填写来源说明" }); } });
最重要的忠告:别把一个 200 行的巨型表单塞进单文件。按业务块拆 sub-form(收货信息一块、支付信息一块),每个 sub-form 是独立的 RHF 子表单或者通过 useFormContext 共享同一个 form 实例。文件拆细,可测、可维护。
八、复用:hook、schema、字段组件
最后一步是把表单从"一次性"变成"可组合"。
自定义 hook 封装:把表单逻辑抽出来,组件只管渲染。
function useProfileForm(user?: User) {
const form = useForm<ProfileValues>({
resolver: zodResolver(profileSchema),
defaultValues: user ?? { email: "", name: "", bio: "" },
});
const save = async (data: ProfileValues) => {
await fetch("/api/profile", { method: "PUT", body: JSON.stringify(data) });
};
return { form, save };
}
新增页和编辑页共用一个 hook,只是 defaultValues 不同——典型 CRUD 套路。
共享 schema:lib/schemas/profile.ts 导出 schema,前端、Server Action、测试用例都 import,校验逻辑永不漂移。
字段组件库:把"输入框 + label + 错误提示 + aria 关联"封装成 <TextField />、<SelectField />,统一设计语言和无障碍行为。一个团队里每个人手写的 input 五花八门,沉淀字段组件是表单工程化的分水岭。
function TextField({ name, label, register, error }: TextFieldProps) {
return (
<label>
{label}
<input aria-invalid={!!error} aria-describedby={`${name}-error`} {...register(name)} />
{error && <span id={`${name}-error`} role="alert">{error.message}</span>}
</label>
);
}
小结
React 表单的水很深,但抓住主线就能稳:
- 选型:新项目首选 React Hook Form,性能和 DX 都比 Formik 强,别再用 useState 硬扛;
- 校验:zod schema 是唯一事实来源,类型与运行时校验一体,Server Action 复用同一份,前后端不再漂移;
- 状态:默认非受控,字段联动用
watch/useWatch,别把表单数据塞进全局 store; - 提交:App Router 项目优先 Server Actions + FormData,天然渐进增强;
isSubmitting管按钮状态,loading 错误都不漏; - 错误:字段级 + 表单级双层,
aria-invalid/role="alert"是底线,可访问性不是可选项; - 复杂场景:
useFieldArray处理数组,稳定 key;superRefine处理条件校验;巨型表单一定要拆 sub-form; - 复用:hook 封装表单逻辑、共享 schema、字段组件库三件套,是团队表单工程化的地基。
金句:表单的复杂度不会消失,只会转移到没设计好的地方——要么转移到库的合理抽象里,要么转移到你下个月的 bug 列表上。
选好工具、写好 schema、管好状态、拆好组件,表单就从"前端地狱"变成可以放心交付的模块。
Related