前置:列表与表单 目标:用 RHF 少写样板;用 Zod 集中定义校验与类型,做出可维护的表单。
引言
受控表单:每个字段 value + onChange + 自己的 error state,字段一多就啰嗦,还容易和校验规则各写一份。
更麻烦的是:校验规则散在 JSX、提交函数、类型声明三个地方。加一个「年龄必须 1–120」的规则,要改三处,漏一处就出 bug。
常见组合:
- React Hook Form(RHF):注册字段、提交、错误状态;大表单更省渲染
- Zod:一份 schema → 运行时校验 + z.infer / z.output 得类型
- @hookform/resolvers:把 Zod 接到 RHF 的 resolver
本篇 demo:姓名 / 邮箱 / 年龄,提交前校验,通过后 alert。
动手
完整代码:react2/14-rhf-zod/demo
cd react2/14-rhf-zod/demo
npm install && npm run dev
依赖已含 react-hook-form、zod、@hookform/resolvers。故意填错看红字;填对提交看 alert / 控制台。
1. 先写 Zod schema
schema 是唯一真相:校验规则 + 错误文案 + 数据类型都从这里长出来。
const schema = z.object({
name: z.string().min(2, '至少 2 个字'),
email: z.string().email('邮箱格式不对'),
age: z
.string()
.min(1, '请填写年龄')
.transform((v) => Number(v))
.refine((n) => Number.isInteger(n) && n >= 1 && n <= 120, '年龄不合法'),
})
type FormInput = { name: string; email: string; age: string }
type FormValues = z.output<typeof schema>
输入框里的年龄是字符串;transform 后提交得到的是 number。 所以 useForm 用「输入类型 / 输出类型」二分:useForm<FormInput, unknown, FormValues>。
| z.input<typeof schema> | 表单里的原始值 | 输入框 / register |
| z.output<typeof schema> | transform 之后的提交值 | onSubmit / 请求体 |
也可以直接:
type FormInput = z.input<typeof schema>
type FormValues = z.output<typeof schema>
2. 接到 RHF
const {
register,
handleSubmit,
formState: { errors, isSubmitSuccessful },
reset,
} = useForm<FormInput, unknown, FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: '18' },
})
<form
onSubmit={handleSubmit((data) => {
console.log(data) // age 已是 number
alert(JSON.stringify(data, null, 2))
})}
>
<input {…register('name')} />
{errors.name && <p className="err">{errors.name.message}</p>}
{/* email / age 同理 */}
</form>
| register('name') | 绑定 name / onChange / ref 等 |
| handleSubmit(fn) | 先跑 resolver,通过才进 fn |
| errors.xxx.message | 展示 Zod 文案 |
| reset() | 回到 defaultValues |
| isSubmitSuccessful | 最近一次提交是否成功 |
| formState.isDirty | 是否改过,可用于「未保存」提示 |
| formState.isSubmitting | 提交中,用于禁用按钮 |
3. 校验时机:默认只在提交时
useForm 默认 mode: 'onSubmit':第一次提交前不打扰用户。 需要「失焦就校验」或「输入即校验」时再改:
useForm<FormInput, unknown, FormValues>({
resolver: zodResolver(schema),
mode: 'onBlur', // 失焦校验
reValidateMode: 'onChange', // 出错后改输入就重新校验
})
| onSubmit | 安静;第一次提交才集中报错 |
| onBlur | 填完一项就提示,适合注册/结算 |
| onChange | 最即时,但容易在用户还没输完时就红一片 |
| onTouched | 碰过才校验,折中 |
不要一上来就 onChange:错误提示太早,反而打扰。
4. 异步提交与提交中状态
真实表单要请求接口,需处理「提交中」和「服务端返回的错误」。
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<FormInput, unknown, FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '', age: '18' },
})
async function onSubmit(data: FormValues) {
try {
await fakeRequest(data)
} catch (e) {
setError('email', { message: '该邮箱已被注册' })
}
}
<form onSubmit={handleSubmit(onSubmit)}>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中…' : '提交'}
</button>
</form>
要点:
- handleSubmit 已经帮你挡掉校验失败的情况,onSubmit 里只需处理业务。
- 服务端字段错误用 setError 挂回对应字段,而不是只弹一个全局 toast。
- 提交按钮用 isSubmitting 禁用,避免重复下单/重复注册。
5. 跨字段校验:确认密码
单字段规则写在字段上;「两个字段必须相等」用对象级 refine:
const passwordSchema = z
.object({
password: z.string().min(8, '至少 8 位'),
confirm: z.string().min(1, '请再输一次'),
})
.refine((data) => data.password === data.confirm, {
message: '两次输入不一致',
path: ['confirm'], // 错误挂到 confirm 字段上
})
path 很重要:不写的话错误会挂在表单根上,errors.confirm 取不到。
6. 非原生控件:Controller
register 适合原生 input / select / textarea。 如果用的是 Ant Design、MUI 这类受控组件,用 Controller 包一层:
import { Controller } from 'react-hook-form'
<Controller
name="age"
control={control}
render={({ field, fieldState }) => (
<>
<NumberInput {…field} />
{fieldState.error && <p className="err">{fieldState.error.message}</p>}
</>
)}
/>
field 里包含 value / onChange / onBlur / ref,别自己再写一份。
7. 动态字段:useFieldArray
「添加多个联系人 / 多条商品」用 useFieldArray:
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' })
{fields.map((field, index) => (
<div key={field.id}>
<input {…register(`contacts.${index}.name` as const)} />
<button type="button" onClick={() => remove(index)}>删除</button>
</div>
))}
<button type="button" onClick={() => append({ name: '' })}>添加联系人</button>
注意 key 用 field.id,不要用数组下标,否则删除中间项时输入状态会错位。
8. 为什么这套
| RHF | 少手写受控样板;按字段订阅,大表单更友好 |
| Zod | 校验与类型一处维护,改规则不容易漏 UI |
| resolvers | 校验逻辑不散落在 onSubmit 里手写 if |
中大型项目表单几乎标配这类方案(或同类:Yup、Valibot 等)。
9. 常见坑
| 忘了 resolver | Zod 规则不生效 |
| register 写错字段名 | 静默对不上 schema |
| 数字输入不处理 string→number | 提交类型和预期不符;用 transform / valueAsNumber |
| 校验文案只写在 JSX | 与 schema 双份维护,易漂移 |
| defaultValues 漏字段 | 受控/非受控切换警告,或重置后值不对 |
| 异步提交不用 isSubmitting | 用户连点,重复提交 |
| refine 不写 path | 错误挂错位置,字段下不显示 |
| 动态字段 key 用 index | 删除中间项后输入框状态错乱 |
10. 调试清单
demo 在练什么
自己改一改:
- 加必填「确认邮箱」用 refine 比相等;
- 加 mode: 'onBlur',看校验时机变化;
- 提交时用 setTimeout 模拟网络,配合 isSubmitting 禁用按钮;
- 把 age 改成受控组件,用 Controller 接入。
下一篇
全局状态 Zustand



