本节目标:理解 React Hook Form 如何减少大表单的更新范围,掌握
register/handleSubmit/formState的标准用法,并学会用zodResolver把 Zod schema 变成表单唯一的规则与类型来源。读完后你能独立搭出一个字段级订阅、类型自动对齐、错误结构统一的表单层。
13.1 React Hook Form + Zod
表单是前端里最容易写、也最容易写坏的组件。它同时牵扯三件事:状态(用户输入了什么)、规则(什么算合法)、类型(代码里怎么描述这份数据)。多数项目的做法是把三件事各写一遍,于是「邮箱必填且格式正确」这个约束在 JSX 里写一遍、在提交函数里写一遍、在 TypeScript 类型里再写一遍——三份副本,改动时必然漏掉一处。
这一节把三件事收敛到一份 Zod schema 上,schema 沿用 zod@3.25.76;RHF 与 resolver 须选支持该版本的组合,并用锁文件固定。性能收益需用实际表单测量,下面的重渲染次数仅用于说明模型。
13.1.1 受控表单的性能天花板
先从最朴素的写法看起:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
</form>
);
}
value + onChange 就是所谓「受控组件」:React 是唯一数据源,每次按键都要 setState。这段代码在 2 个字段时毫无问题,到 50 个字段时就成了性能事故——因为状态提在 LoginForm 上,任何一个字段变化都会重渲染整个表单树。用户敲一个字符,50 个 <input> 全部重新渲染,输入延迟肉眼可见。
常见的补救是拆组件、上 memo、把 state 下移。这些手段有效,但它们是在跟 React 的渲染模型对抗:输入框的「值」本质上属于 DOM 自己的状态,硬把它搬到 React 里再同步回去,等于绕了一大圈。
13.1.2 非受控 + 订阅:RHF 的模型
React Hook Form(下称 RHF)走的是另一条路:输入框保持非受控,值留在 DOM 里;RHF 只通过 ref 读值,并让组件按字段名订阅变化。
| 维度 | 受控(useState) | 非受控(RHF) |
|---|---|---|
| 数据源 | React state | DOM 自身 |
| 按键触发的重渲染 | 整个表单组件 | 仅订阅该字段的组件 |
| 取值时机 | 每次 onChange | 提交 / 校验时按需读 |
| 初始值 | value={x} | defaultValues |
| 重渲染次数(50 字段) | 每次按键 50 个 | 每次按键 0~1 个 |
关键在于 formState 是订阅式的:只有当你解构出 formState.errors,组件才订阅错误变化;解构出 formState.isSubmitting,才订阅提交态。RHF 内部用一个 Proxy 记录你读了哪些字段,从而把重渲染范围压到最小。
这也是 RHF 文档反复强调的一条纪律:只解构你需要的那几个字段。
13.1.3 三个核心 API 的职责
RHF 的 API 面很窄,日常只需要三个:
| API | 作用 | 返回 |
|---|---|---|
register(name, options?) | 把输入框登记到表单 | { name, onChange, onBlur, ref } |
handleSubmit(onValid, onInvalid?) | 包一层提交入口,先校验后回调 | (e) => Promise<void> |
formState | 订阅表单状态 | errors / isSubmitting / isDirty / touchedFields |
register 的 options 里几个值得记的:
register('age', { valueAsNumber: true }); // 直接把 value 转成 number
register('items', { deps: ['type'] }); // 依赖字段变化时联动重新校验
register('tmp', { shouldUnregister: true }); // 组件卸载时从表单值里移除
接了 Zod resolver 之后,options 里的 required / min / max 只用于浏览器原生提示,真正的规则以 schema 为准。两处都写会出现「schema 说合法、浏览器说非法」的错位,所以约定是:规则只写在 schema 里。
13.1.4 最小可用示例
先装上依赖:
pnpm add react-hook-form zod @hookform/resolvers
一个完整可跑的表单:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { schema, type FormValues } from './schema';
export function ProfileForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { nickname: '', email: '' },
});
const onSubmit = async (values: FormValues) => {
await api.save(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input {...register('nickname')} aria-invalid={!!errors.nickname} />
{errors.nickname && <p role="alert">{errors.nickname.message}</p>}
<input {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<button disabled={isSubmitting}>保存</button>
</form>
);
}
register('email') 返回的四个属性展开到 <input> 上即完成绑定。注意 noValidate:原生校验气泡会抢在校验器之前拦下提交,导致你永远看不到自己的错误文案。aria-invalid 与 role="alert" 是顺手就能补上的无障碍细节,屏幕阅读器会读出错误。
13.1.5 用 Zod 描述规则
Zod 是运行时的 schema 库,它最值钱的地方是类型可以从 schema 推导出来,不用手写两遍。
import { z } from 'zod';
export const schema = z.object({
nickname: z.string().min(2, '昵称至少 2 个字符').max(20, '昵称最多 20 个字符'),
email: z.string().email('邮箱格式不正确'),
age: z.coerce.number().int('年龄必须是整数').min(18, '需年满 18 岁'),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
});
export type FormValues = z.infer<typeof schema>;
z.infer<typeof schema> 展开后是:
type FormValues = {
nickname: string;
email: string;
age: number;
role: 'admin' | 'editor' | 'viewer';
};
类型不是「另写的」,是从 schema 算出来的。改了 max(20) 为 max(30),类型不动(都是 string),但运行时行为自动跟上;把 role 从 z.enum 改成 z.string(),类型会立刻变宽,所有 switch 分支的 never 兜底会报错——编译器会替你找到该改的地方。
13.1.6 zodResolver 把两者焊在一起
resolver 是 RHF 与校验库之间的适配层。zodResolver(schema) 做的事很直白:
- 提交或触发校验时,把当前表单值交给
schema.safeParse; - 解析失败时,把
ZodError.issues转成 RHF 的FieldErrors,按 path 挂到对应字段; - 解析成功时,把
parse的输出值(已转换、已填默认值)传给onSubmit。
第 3 点容易被忽略:onSubmit 拿到的是 schema 输出,不是原始输入。所以 age 字段即使输入框给的是字符串 "20",因为用了 z.coerce.number(),到 onSubmit 里已经是 number 20;role 没填时也会被 .default('viewer') 补上。
没有 resolver 时,RHF 只能做内置的 required / min / max / pattern 这类规则,表达力弱且要另写类型。接了 Zod 之后,规则就是类型,类型就是规则。
13.1.7 校验时机 mode
默认情况下 RHF 在提交时才校验。可以按体验需求调整:
mode | 触发时机 | 适合 |
|---|---|---|
onSubmit | 仅提交时(默认) | 字段多、讨厌打扰用户 |
onBlur | 失焦时 | 大多数业务表单,推荐 |
onChange | 每次输入 | 实时搜索、即时反馈 |
onTouched | 首次失焦后转为 onChange | 想「先松后紧」的体验 |
all | blur + change 都触发 | 需要最及时的反馈 |
useForm<FormValues>({
resolver: zodResolver(schema),
mode: 'onBlur',
reValidateMode: 'onChange', // 出错后改为输入即校验,让错误尽快消失
});
reValidateMode 常被忽略:设成 onChange 后,字段一旦报错,用户边改错误边消失,比等到再次失焦才更新要舒服得多。
13.1.8 读值:watch 与 useWatch
想在渲染中读某个字段的值,有两个 API,差别在订阅范围:
// 一、watch:在表单组件内部调用,会让整个表单组件重渲染
const email = watch('email');
// 二、useWatch:独立 hook,把重渲染限制在调用它的子组件里
const email = useWatch({ control, name: 'email' });
watch 的代价是它订阅在调用它的那个组件上——如果 Form 顶层调了 watch('email'),每次邮箱输入都会重渲染整个表单,等于把 RHF 的性能优势还了回去。正确的做法是把依赖某字段的 UI 抽成子组件,在里面用 useWatch:
function RolePreview({ control }: { control: Control<FormValues> }) {
const role = useWatch({ control, name: 'role' });
return <span>当前角色:{role}</span>;
}
同一条纪律的另一种表述:watch 只在你确实需要整表联动时用,其余场景一律 useWatch 并下沉组件。顺带一提,useWatch 默认只返回值,要拿它的校验状态得用 formState。
13.1.9 接入第三方受控组件
RHF 的非受控模型遇到「只暴露 value / onChange 的受控组件」时会失效——比如大多数 UI 库的 Select、日期选择器。这时用 Controller 把它桥回来:
import { Controller, useForm } from 'react-hook-form';
function Form() {
const { control, handleSubmit } = useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="role"
control={control}
render={({ field, fieldState }) => (
<>
<Select value={field.value} onChange={field.onChange} onBlur={field.onBlur} />
{fieldState.error && <span>{fieldState.error.message}</span>}
</>
)}
/>
</form>
);
}
field 提供 value / onChange / onBlur / ref,fieldState 提供该字段的 error / isDirty / isTouched。把 field 原样透传给组件即可,不要自己拼装 onChange 逻辑,否则脏值追踪会失准。
原则很简单:原生 <input> 用 register,受控组件用 Controller。不要为了统一而全用 Controller,那等于放弃了非受控的性能优势。
13.1.10 完整示例:注册表单
把上面所有零件拼成一个带跨字段校验与默认值的注册表单。schema 里用 refine 表达「两次密码一致」这类跨字段规则:
export const registerSchema = z
.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
confirm: z.string(),
plan: z.enum(['free', 'pro']).default('free'),
agree: z.literal(true, { errorMap: () => ({ message: '需同意服务条款' }) }),
})
.refine((v) => v.password === v.confirm, {
message: '两次输入的密码不一致',
path: ['confirm'], // 错误挂到 confirm 字段,而不是整个对象
});
path 是关键:不写的话错误会挂在根对象上,errors.confirm 永远是 undefined,界面上什么也不显示。这是跨字段校验最常见的「校验生效了但看不见错误」的原因。
const { register, handleSubmit, formState } = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
mode: 'onBlur',
defaultValues: { email: '', password: '', confirm: '', agree: false },
});
提交时再补一层服务端错误回填(setError),细节见 13.2:
const onSubmit = async (values: RegisterValues) => {
try {
await api.register(values);
} catch (e) {
setError('email', { type: 'server', message: '该邮箱已被注册' });
}
};
注意 agree 用 z.literal(true) 而不是 z.boolean():后者允许 false,无法表达「必须勾选」。.default(false) 也别加,否则未勾选时会被默认值补成 false 而绕过校验——这里要的就是「没勾就报错」。
13.1.11 五个常见坑
一、defaultValues 与 defaultValue 混淆。 RHF 的初始值统一由 defaultValues 提供,写在 <input defaultValue> 上 RHF 读不到,会出现「看着有值、提交是空」。异步数据回填要用 reset(data),而不是改 defaultValues(它只在首次挂载时生效)。
二、z.coerce.number() 忘了加。 输入框的 e.target.value 永远是字符串,直接 z.number() 会稳定报 Expected number, received string。凡是数字字段,要么 z.coerce.number(),要么在 register 时用 valueAsNumber: true。
三、z.input 与 z.output 不是一回事。 一旦用了 coerce / transform / default,输入类型和输出类型就分叉了。本节例子里 useForm<FormValues> 用的其实是输出类型,严格来说应该写成三段泛型,这个问题留到 《TypeScript编程实战》13.2 表单类型推导与错误映射
专门讲。
四、refine 忘了写 path。 错误挂到根对象上,界面看不到任何提示,用户只会觉得「按钮点了没反应」。
五、resolver 版本错配。 @hookform/resolvers 的次版本与 Zod 大版本强绑定(v3 对应 Zod 3,v5 起同时支持 Zod 3/4)。装错版本的症状是运行时报 schema.parse is not a function,或类型上 zodResolver 与 useForm 的泛型不兼容。
13.1.12 与其它章节的衔接
组件的 props 泛型设计见 《TypeScript编程实战》11.1 组件 props 与泛型组件 ;表单提交后的服务端状态刷新(失效缓存、乐观更新)见 《TypeScript编程实战》14.2 乐观更新与缓存失效 。服务端契约校验同样复用 Zod,见 《TypeScript编程实战》5.1 HTTP 服务与路由(Fastify / Hono) ,端到端类型安全见 《TypeScript编程实战》16.1 tRPC 端到端类型安全 ;动态字段与嵌套数组见 《TypeScript编程实战》13.3 复杂表单与动态字段 。
站内延伸阅读:TypeScript 与 Zod 运行时校验 、Node.js 中的 Zod 校验实践 、运行时类型校验与类型安全 、前端表单与校验架构 、React 与 TypeScript 实践指南 。
小结
本节把表单的三份副本收敛成了一份 schema。性能上,RHF 用非受控输入 + 字段级订阅替代了受控组件的整树重渲染,formState 只解构所需字段、需要读值时用 useWatch 而非顶层 watch,是发挥性能的两个前提;规则上,zodResolver 把 safeParse 的失败结果按 path 映射成 FieldErrors,成功结果则把输出值交给 onSubmit;类型上,z.infer 让类型从 schema 推导,改规则时类型自动跟随,漏改的地方由编译器报出来。
接入方式只有两条规则:原生输入用 register,受控组件用 Controller。校验时机用 mode 与 reValidateMode 分开控制,前者管「第一次什么时候校验」,后者管「出错后什么时候重新校验」。跨字段规则用 refine 表达,并且一定要给 path,否则错误无处可挂。
至于 coerce / transform / default 带来的输入输出类型分叉,正是下一节的主题。下一节 《TypeScript编程实战》13.2 表单类型推导与错误映射
会把 z.input、z.output、z.infer 三者的关系讲透,并给出「服务端返回的错误如何回填到表单」这套工程上一定会遇到的映射方案。
阅读导航:上一节:12.3 SSR·ISR 数据流类型 · 下一节:13.2 表单类型推导与错误映射 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。