欢迎光临
我们一直在努力

React 表单:React Hook Form + Zod

前置:列表与表单 目标:用 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>

API作用
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', // 出错后改输入就重新校验
})

mode体验
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. 调试清单

  • 先在控制台 console.log(schema.safeParse(values)),确认是 schema 问题还是 UI 绑定问题。
  • errors 打印出来,看错误挂在哪个字段。
  • 检查 register('xxx') 与 schema 字段名是否完全一致。
  • 数字/布尔字段注意输入类型与提交类型,不要混用。
  • 接 UI 库时确认用了 Controller,而不是直接 register。

  • demo 在练什么

  • Zod object + 自定义错误文案
  • zodResolver + register + errors
  • transform 年龄,体会输入类型 vs 提交类型
  • 自己改一改:

    • 加必填「确认邮箱」用 refine 比相等;
    • 加 mode: 'onBlur',看校验时机变化;
    • 提交时用 setTimeout 模拟网络,配合 isSubmitting 禁用按钮;
    • 把 age 改成受控组件,用 Controller 接入。

    下一篇

    全局状态 Zustand

    赞(0)
    未经允许不得转载:171主机测评 » React 表单:React Hook Form + Zod
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址