React 表单的痛点:
- 每次输入触发整页 re-render(useState 模式)
- 校验逻辑分散
- 类型推导 / 错误提示不一致
- 异步校验 / 提交状态难管
react-hook-form (RHF) + zod 组合是 2024 业界共识:
RHF 用 ref 而非 state 避免重渲,zod 提供 schema-first 校验 + 类型推导。
安装
npm i react-hook-form zod @hookform/resolvers
5 行版
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 位'),
age: z.coerce.number().int().min(0).max(150),
})
type FormData = z.infer<typeof schema>
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', age: 18 },
})
const onSubmit = async (data: FormData) => {
await fetch('/api/login', { method: 'POST', body: JSON.stringify(data) })
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="邮箱" />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<input type="number" {...register('age')} />
{errors.age && <p>{errors.age.message}</p>}
<button disabled={isSubmitting}>登录</button>
</form>
)
}
注意:
z.infer<typeof schema>自动推 FormData 类型,schema 改字段自动同步register('email')把 input 注册到表单系统,不用 useState- 提交时 zod 校验,错的字段进
errors
性能:只渲染必要部分
import { useFormState, useWatch } from 'react-hook-form'
function EmailPreview({ control }) {
// 只在 email 字段变了时重渲,整个表单不动
const email = useWatch({ control, name: 'email' })
return <div>预览: {email}</div>
}
嵌套 / 数组字段
const schema = z.object({
name: z.string(),
emails: z.array(z.object({
address: z.string().email(),
primary: z.boolean(),
})).min(1),
})
import { useFieldArray } from 'react-hook-form'
const { fields, append, remove } = useFieldArray({ control, name: 'emails' })
return (
<>
{fields.map((f, i) => (
<div key={f.id}>
<input {...register(`emails.${i}.address`)} />
<input type="checkbox" {...register(`emails.${i}.primary`)} />
<button onClick={() => remove(i)}>×</button>
</div>
))}
<button onClick={() => append({ address: '', primary: false })}>加</button>
</>
)
useFieldArray 优化动态列表场景,比手动 useState<Email[]> 性能好。
复杂校验
跨字段
const schema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine(d => d.password === d.confirm, {
message: '两次密码不一致',
path: ['confirm'],
})
异步(如检查用户名是否已存在)
const schema = z.object({
username: z.string().min(3).refine(async (val) => {
const r = await fetch(`/api/check-username?u=${val}`)
return (await r.json()).available
}, { message: '用户名已被占用' }),
})
zod 支持 async refine,RHF 自动 await。
transform / coerce
const schema = z.object({
// <input type="number" /> 实际是字符串,z 转 number
age: z.coerce.number().int(),
// 字符串前后去空格
name: z.string().trim().min(1),
// 把 "yes"/"no" 字符串变 boolean
consent: z.string().transform(v => v === 'yes'),
})
异步提交状态
const { handleSubmit, formState: { isSubmitting, isSubmitSuccessful } } = useForm(...)
return (
<form onSubmit={handleSubmit(onSubmit)}>
...
<button disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
{isSubmitSuccessful && <p>成功!</p>}
</form>
)
服务端错误回填
const { setError } = useForm(...)
const onSubmit = async (data) => {
try {
await api.create(data)
} catch (e) {
if (e.code === 'EMAIL_TAKEN') {
setError('email', { type: 'manual', message: '邮箱已注册' })
}
}
}
与 UI 组件库(Radix UI / shadcn / Mantine)配合
import { Controller } from 'react-hook-form'
<Controller
control={control}
name="country"
render={({ field }) => <Select {...field} options={countries} />}
/>
Controller 把不支持 ref 的受控组件包起来。
共用 schema 到后端
zod schema 是纯 TypeScript,可以在 FastAPI / Express / Hono / tRPC 后端
共用。前后端用同一份校验,类型一致:
// shared/schema.ts
export const userSchema = z.object({ ... })
// frontend
useForm({ resolver: zodResolver(userSchema) })
// backend (hono)
import { zValidator } from '@hono/zod-validator'
app.post('/api/user', zValidator('json', userSchema), (c) => { ... })
何时不用 RHF
简单 1-2 字段的表单直接 useState 就够,引入 RHF 反而麻烦。
3+ 字段 + 校验 + 异步提交时 RHF 收益最大。
踩过的坑
- 忘了
defaultValues:第一次渲染 input value 是 undefined,React 报
"uncontrolled to controlled" 警告。永远显式给 defaultValues。 mode: 'onChange'让每次输入都校验 → 慢且打扰用户。默认 'onSubmit'
最稳;onBlur 是折中。- 跨大版本升 RHF:v6 → v7 API 变化大。lockfile + 一次性升级。
- zod refine 异步:会让 onChange 校验变慢;尽量 onBlur 异步校验。
登录后参与评论。