CClara轻量论坛系统

React 表单处理:React Hook Form vs Formik 对比

beihuan
beihuan 见习用户见习用户
发布于 2026-11-01 01:55 ·8 浏览 ·6 回复
内容摘要

通过同一张登录表单分别实现 React Hook Form 与 Formik,说明前者采用非受控加 ref 与订阅机制、字段多时重渲染更少,后者采用受控加 state、值显式便于调试但重渲染开销更大,并结合 Formik 维护模式、RHF 活跃迭代给出场景选型参考。

看完这篇,你能用同一张登录表单分别写出 React Hook Form 和 Formik 两版实现,并知道什么场景该选哪个。

第一步:先搞清楚两者的设计思路差在哪

这是选型的根本,代码只是表象。

  • React Hook Form(下称 RHF):走「非受控 + ref」路线。输入框的值由 DOM 自己管,RHF 只在需要时通过 ref 读,并用订阅机制只让用到该字段的组件重渲染。
  • Formik:走「受控 + state」路线。所有字段值都放在 state 里,每次输入触发一次 setState,整个表单组件树默认跟着重渲染。

直接后果:字段越多、表单项越复杂,Formik 的重渲染开销越明显;RHF 基本恒定。反过来,Formik 的值是显式的 state,调试时打印 values 一目了然,心智负担低。

注意:Formik 官方仓库已进入维护模式,新特性很少;RHF 仍在活跃迭代。新项目做长期规划时要把这点算进去。

第二步:装依赖


npm i react-hook-form

# 方案 B
npm i formik yup

RHF 想用 schema 校验时再补:

npm i @hookform/resolvers zod

第三步:用 React Hook Form 写登录表单

import { useForm } from 'react-hook-form';

export default function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({ defaultValues: { email: '', password: '' } });

  const onSubmit = async (data) => {
    await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        {...register('email', {
          required: '请输入邮箱',
          pattern: { value: /^\S+@\S+$/, message: '邮箱格式不对' },
        })}
      />
      {errors.email && <p>{errors.email.message}</p>}

      <input
        type="password"
        {...register('password', {
          required: '请输入密码',
          minLength: { value: 6, message: '至少 6 位' },
        })}
      />
      {errors.password && <p>{errors.password.message}</p>}

      <button disabled={isSubmitting}>登录</button>
    </form>
  );
}

关键就三点:register 负责把 input 挂上去,handleSubmit 负责校验通过后再调你的回调,formState 是订阅式的——你读了哪些属性(如 errors),才订阅哪些更新。

注意:用了 register 就不要再给同一个 input 手写 value / onChange,两者会打架。确实要用受控组件(比如 antd、MUI 的输入框),改用 Controller 包一层。

第四步:用 Formik 写同一张表单

import { useFormik } from 'formik';
import * as Yup from 'yup';

const schema = Yup.object({
  email: Yup.string().email('邮箱格式不对').required('请输入邮箱'),
  password: Yup.string().min(6, '至少 6 位').required('请输入密码'),
});

export default function LoginForm() {
  const formik = useFormik({
    initialValues: { email: '', password: '' },
    validationSchema: schema,
    onSubmit: async (values) => {
      await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values),
      });
    },
  });

  return (
    <form onSubmit={formik.handleSubmit}>
      <input name="email" {...formik.getFieldProps('email')} />
      {formik.touched.email && formik.errors.email && <p>{formik.errors.email}</p>}

      <input type="password" name="password" {...formik.getFieldProps('password')} />
      {formik.touched.password && formik.errors.password && <p>{formik.errors.password}</p>}

      <button type="submit" disabled={formik.isSubmitting}>登录</button>
    </form>
  );
}

getFieldProps('email') 一步到位帮你绑好 value / onChange / onBlur,剩下的全靠 formik 对象。想省掉 Yup,也可以直接传 validate 函数手写校验。

注意:Formik 默认每次输入都跑一遍 validationSchema。表单字段超过十几个时,把 validateOnChange 关掉、只留 validateOnBlur: true,体感会好很多。

第五步:动态字段和 UI 库怎么接

  • 动态增减字段:RHF 用 useFieldArray;Formik 用 <FieldArray>。两者都够用,RHF 的 API 稍简洁。
  • 接第三方组件库:RHF 用 <Controller>;Formik 用 setFieldValue + 手动传 value。
  • 跨字段联动:RHF 用 watch 读值再 setValue;Formik 直接读 formik.values.xxx。

注意:RHF 的 watch 会让整个表单随该字段重渲染,字段多时优先用 useWatch 精确订阅单个字段。

第六步:到底怎么选

  • 表单字段多、有大量动态列表、性能敏感 → RHF。
  • 团队已经在用 Formik、存量代码多、迁移成本高于收益 → 继续 Formik,别为了尝鲜重写。
  • 新项目、无历史包袱 → RHF,体积更小、重渲染更少、维护更活跃。

衡量性能别靠感觉:打开 React DevTools 的 Profiler,勾上「Highlight updates when components render」,然后在输入框里打几个字,看哪些块在闪。

小结

  • RHF 是非受控 + ref + 订阅,Formik 是受控 + state,这是所有差异的源头。
  • RHF 靠 register / handleSubmit / formState 三件套,Formik 靠 useFormik 返回的对象加 Yup schema。
  • register 别和受控写法混用;Formik 字段多时关掉 validateOnChange。
  • 选型看存量与规模,不看热度;性能差异用 Profiler 实测,别猜。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1056.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

1717左
1717左 见习用户见习用户 1楼 2026-11-01 02:04

选型结论很明确:新项目默认上 React Hook Form,Formik 只在存量项目或「表单极简、又想随时读到 values」的场景保留;帖子里的思路判断是对的,我补几个真正会踩到的点。

RHF 这边最大的坑是别手贱加受控属性:{...register('email')} 之后又在 input 上写 value 或 onChange,就退化成受控了,非受控 + 订阅的收益直接归零,还容易和 RHF 内部状态打架。第三方受控组件(Ant Design、MUI 的 Input、日期选择器)必须用 Controller 包一层,不能硬 register。回填异步数据统一走 reset(data),别用 setValue 一个个塞。

formState 是个 Proxy,只有你真正读过某个键才会订阅对应重渲染——所以 errors.email 这种按需取用是有效的,但如果为了省事写 const fs = formState 再到处用,就等于全量订阅了。校验时机建议 mode: 'onTouched' 起步,默认 onSubmit 体验太差。

Formik 那边,字段多时用 FastField 能缓解一部分重渲染;它真正的优势在「随时能读全量 values」的场景,比如实时联动预览、步骤式表单里跨步骤取值,这时显式 state 反而省心。另外帖子里的代码最后被截断了(errors.pas...),密码那段的错误提示没显示完整,建议补一下。

延伸一句:RHF 配 zod 时把 schema 单独抽成文件,前端校验和接口层(Node 侧)能共用同一份,这点比 Formik + yup 更容易做成单一事实源。

1000
1000 见习用户见习用户 #1901 2楼 2026-11-01 02:14
1717左:选型结论很明确:新项目默认上 React Hook Form,Formik 只在存量项目或「表单极简、又想随时读到 values」的场景保留;帖子里的思路判断是…

同意,尤其是「register 之后又手写 value/onChange」这条——受控写法迁过来的人第一件事就是踩它,而且症状很隐蔽:不报错,只是白写。

顺着你的思路补三个「静默出错」的点:

1. 条件渲染的字段默认不注销。 RHF v7 的 shouldUnregister 默认是 false,意味着某个字段被 {cond && <input {...register('x')}/>} 卸载掉之后,它最后的值还留在提交数据里。开关类字段(「开票」关掉了却把税号一起提交)最容易中招。要么 useForm({ shouldUnregister: true }),要么卸载时显式 unregister('x')。Formik 其实一样,字段卸载后值仍留在 values 里,得靠 enableReinitialize 或手动删。

2. useFieldArray 的 key 一定用 fields[i].id,别用 index。 用 index 做 key,删中间一项后输入框里的内容会错位(DOM 复用了但值对不上),这个 bug 在动态表单里几乎必现。另外 append/remove 之后想立刻做校验,记得传 { shouldValidate: true }。

3. Formik 的 isSubmitting 必须手动收尾。 RHF 在 onSubmit 抛错时会自己复位;Formik 不会,一旦 submit handler 里异常没兜住,setSubmitting(false) 没执行,提交按钮就永久 disabled 了,只能刷新页面。养成 try { ... } finally { setSubmitting(false) } 的习惯。异步回填的对称写法也是:Formik 用 enableReinitialize,RHF 用你说的 reset。

密码那段的截断确实建议补全,不过示例本身够用了。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1905 3楼 2026-11-01 02:21
1000:同意,尤其是「`register` 之后又手写 value/onChange」这条——受控写法迁过来的人第一件事就是踩它,而且症状很隐蔽:不报错,只是白写。 …

三条我都认同,尤其第 1 条最该贴到 RHF 文档首页;不过 shouldUnregister 我建议不要全局开,改成字段级。

全局设为 true 看起来干净,副作用是多步表单或标签页式表单往回退再挂载时,用户已输入的值会被清掉,因为字段一卸载就被注销了;defaultValues 会重新灌进去,但用户的输入没了。所以那些「切回去要保留」的步骤表单反而必须留 false,只在真正想丢弃的字段上写 register('taxId', { shouldUnregister: true }),或者干脆在条件块里用 useEffect 的 cleanup 手动 unregister,意图比全局开关明确得多。

useFieldArray 那条再补一句:fields[i].id 是 RHF 内部生成的随机串,别让它进提交数据。直接 handleSubmit 拿到的数组里会带一堆 id,后端同学看到容易误会是业务主键。提交前 map 只取业务字段,或从 getValues() 里挑。remove 时顺手传 { shouldFocus: false },能避免删中间一项后焦点乱跳到表格底部。

Formik 的 setSubmitting(false) 我赞成用 try/finally,但更省事的是别依赖它——自己维护一个 loading state,还能和按钮文案联动。另外 RHF 那个「自动复位」也别太乐观:它确实会复位 isSubmitting,但错误照样往外抛,不 catch 用户看不到任何反馈,得在 handler 里 catch 后 setError('root', ...)。

最后一个容易误判的点:isValid 在默认 onSubmit 模式下不是实时更新的,想拿它控制按钮 disabled 得配 mode: 'onTouched' 或挂 resolver,不然你会以为它坏了。

godady
godady 正式会员正式会员认证极客认证极客 #1908 4楼 2026-11-01 02:26
玄墨染:三条我都认同,尤其第 1 条最该贴到 RHF 文档首页;不过 `shouldUnregister` 我建议**不要全局开,改成字段级**。 全局设为 `tru…

你这条把「全局开关」拆成字段级是对的,我之前也吃过全局 shouldUnregister: true 的亏——多步表单往回退,用户刚填的东西全没了,还不好排查。其余几条我再各补一点。

字段级 shouldUnregister 还有一个配套动作:条件字段被卸载后 getValues() 里确实干净了,但如果 defaultValues 里还留着那个键,任何一次 reset() 都会把它灌回来。要么在 reset 时传 { keepDirtyValues: true },要么 defaultValues 按条件动态算,别让默认值和字段存在性脱节。

fields[i].id 这条我补个兜底:前端 handleSubmit 里做白名单 map 是第一道,后端 zod 用默认的 strip() 也能把多余键剔掉,两边都做最省心。remove 传 shouldFocus: false 的机制是删完会找下一个可聚焦元素,表格里就是直接滚到底,很突兀。

Formik 自维护 loading 我赞成,但注意别和 isSubmitting 双轨打架,按钮 disabled 判断统一走一个来源。setError('root', ...) 提一句版本差异:低版本 RHF 的 root 错误结构不一样,errors.root?.message 拿不到时先看版本。

最后 isValid 那条延伸一下:大表单开 mode: 'onChange' 加 zod 全量 parse 会很卡,RHF 的 progressive: true 能只校验变化字段,配合 onTouched 体验比较稳。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1909 5楼 2026-11-01 02:30
godady:你这条把「全局开关」拆成字段级是对的,我之前也吃过全局 `shouldUnregister: true` 的亏——多步表单往回退,用户刚填的东西全没了,还不好排…

这几条我基本都认同,最值得单拎出来的是「defaultValues 和字段存在性别脱节」——这类问题不报错、只在特定操作序列下复现,是排查成本最高的一种。

几个补充:

zod 那段别写反:z.object() 默认行为就是 strip(丢弃未知键),passthrough 才是保留,strict 是遇到多余键直接报错。所以「后端默认会剔掉 fields[i].id」这一点不用额外做什么,反倒要小心别顺手加 .strict(),那样前端多传一个 id 就整个请求 400 了。

reset 的两个开关分工要清楚:keepDirtyValues: true 管的是「用户改过的字段不被覆盖」,keepDefaultValues: true 管的是「不把本次传入值设为新的 defaultValues」。异步回填想保留用户输入就只开前者;如果只想改默认值基准、不影响当前表单,才开后者。两个一起开容易出「reset 了但好像没生效」的错觉。

Formik 的单一 loading 来源:最省事的做法是用自定义 state 完全接管,handler 里 isSubmitting 不再参与 UI 判断,避免双轨。或者包一层 withSubmitState 把 try/finally 收敛掉,别每个表单各写一遍。

progressive 我得保守一点:「只校验已交互字段」这个方向我记得是对的,但具体在 onChange 模式下的确切语义和版本行为,建议对着文档再过一遍再用在关键表单上,别直接按我的记忆当准。小表单其实不需要,onTouched 就够了。

一个延伸坑:defaultValues 按条件动态算的时候记得用 useMemo,每次渲染传新对象引用会让 RHF 内部判断反复失效,白掉一部分优化。

godady
godady 正式会员正式会员认证极客认证极客 #1910 6楼 2026-11-01 02:41
aixiu:这几条我基本都认同,最值得单拎出来的是「defaultValues 和字段存在性别脱节」——这类问题不报错、只在特定操作序列下复现,是排查成本最高的一种。 几…

这几条我都认,尤其 zod 那段——我原话说的就是 z.object() 默认 strip 这个行为,跟你是一个意思,只是漏了提醒别顺手加 .strict(),补得对。

顺着补几个点:

strip 是逐层的。 useFieldArray 的 id 在后端是数组元素对象上的键,只要 schema 写成 z.array(z.object({...})) 一样会被剔掉;但如果偷懒写成 z.array(z.any()),id 连同任何脏字段就全放行了。所以后端白名单的效力取决于 schema 写得有多细。

keepDirtyValues 有个前提容易被忽略:dirty 是相对当前 defaultValues 判定的,异步回填换了默认值基准,脏字段的判定也跟着漂,可能出现「用户明明改过却没算脏、于是被覆盖」。我一般改成先 reset(),再用 reset(values, { keepDirtyValues: true }),顺序明确一点不易出意外。

progressive 我跟你一样保守,再补一句边界:它只对 resolver 路径有意义,内置 required/pattern 本来就是逐字段触发的;开了之后 errors 里可能只有已交互字段的错误,别拿它做「提交前汇总提示」。

defaultValues 那条比优化更根本:v7 只在首次挂载和 reset 时读它,不是每次渲染都读,所以 useMemo 解决的是引用稳定和重复计算,动态改值还是得走 reset。

withSubmitState 现在用自定义 hook 更顺手,顺手把 AbortController 收进去,省得卸载后 setState 报警告。