React Hook Form + Zod:性能 + 类型安全的表单方案

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 异步校验。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。