为什么不直接 useState
3 个字段的表单 useState 够用。10+ 字段就力不从心:
- 每个字段一个 setState → re-render 风暴
- 校验规则散在各处
- 错误信息 / touched / dirty 状态各自管
- 提交时拼数据麻烦
React Hook Form(RHF)
npm i react-hook-form
import { useForm } from 'react-hook-form';
type Form = { email: string; password: string };
function Login() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Form>();
return (
<form onSubmit={handleSubmit(async (data) => {
await api.login(data);
})}>
<input {...register('email', { required: '必填', pattern: /^\S+@\S+$/ })} />
{errors.email && <span>{errors.email.message as string}</span>}
<input type="password" {...register('password', { minLength: 6 })} />
{errors.password && <span>至少 6 位</span>}
<button disabled={isSubmitting}>登录</button>
</form>
);
}
核心思路:非受控 + ref 模式,输入字段不触发 re-render,性能极佳。
配 Zod 做校验
npm i zod @hookform/resolvers
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
email: z.string().email('邮箱格式不对'),
password: z.string().min(6, '至少 6 位'),
age: z.coerce.number().int().positive().optional(),
});
type Form = z.infer<typeof schema>;
const { register, handleSubmit, formState: { errors } } = useForm<Form>({
resolver: zodResolver(schema),
});
Zod 同时给出:
- 运行时校验(前端)
- TypeScript 类型(
z.infer) - 同一个 schema 可以用在后端 / Server Actions / API 路由
受控字段(Select、DatePicker 等)
第三方组件(如 Radix Select)大多不接受 ref,要用 Controller:
import { Controller } from 'react-hook-form';
<Controller
name="country"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectItem value="cn">中国</SelectItem>
<SelectItem value="us">美国</SelectItem>
</Select>
)}
/>
异步校验
<input {...register('username', {
validate: async (v) => {
const r = await fetch(`/api/check?u=${v}`);
const { exists } = await r.json();
return exists ? '用户名已被注册' : true;
},
})} />
Server Actions + Form(Next.js 现代写法)
'use server';
import { z } from 'zod';
const schema = z.object({ email: z.string().email() });
export async function subscribe(prev: any, formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.flatten() };
await db.subscriber.create({ data: parsed.data });
return { success: true };
}
'use client';
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? '提交中…' : '订阅'}</button>;
}
function Form() {
// React 19+:useActionState 从 'react' 导入(不是 react-dom 的 useFormState——那个已废弃)
const [state, action, isPending] = useActionState(subscribe, null);
return (
<form action={action}>
<input name="email" />
{state?.error && <p>{JSON.stringify(state.error)}</p>}
<SubmitButton />
</form>
);
}
不需要 RHF / Zod 客户端集成,整个表单不需要 useState。
三种方案选哪个
| 方案 | 适用 |
|---|---|
| useState 直管 | 1-3 字段 |
| React Hook Form + Zod | 客户端复杂表单(10+ 字段、动态字段、复杂校验) |
| Server Actions + Zod | Next.js / 表单大多是提交即结束 |
中等复杂度(5-10 字段、需要实时校验)→ RHF + Zod 仍是最稳。
常见模式
多步表单:useFieldArray 管动态字段;上下步用 setValue / getValues / trigger(['fieldA', 'fieldB']) 校验当前步。
文件上传:必须是非受控,<input type="file" {...register('file')} />,提交时 formData.append('file', data.file[0])。
错误聚焦:useForm({ shouldFocusError: true })——校验失败自动 focus 第一个错误。
→ 下一篇 动画