React 19 useActionState 实战:我替换掉 react-hook-form 后,省了 42 行但踩了 3 个坑

🔑 关键词:React 19, useActionState, react-hook-form, Server Actions, useFormStatus

📖 摘要:带具体版本、代码和踩坑记录的 React 19 表单方案对比,给出 useActionState 和 react-hook-form 的选型边界。

上周三凌晨 1 点,我在改一个商家入驻表单。12 个字段,其中营业执照编号要远程校验。原来用 react-hook-form@7.54.2 + zod@3.24.1 + @hookform/resolvers@3.9.1,代码 118 行。产品说“就加一个字段”,但那个字段要调企业信息 API,还要防抖。我改到第 3 版,Controller 套 Controller,头大。第二天看到 React 19 的 useActionState 稳定了,决定用 Next.js 15.1.3 试一把。结果代码 76 行,少 42 行,但踩了 3 个坑。下面是我的真实对比,不是官方文档复读。

图片

先给结论,别急着换。useActionState 不是表单库替代品,它只是“提交状态机”的语法糖。真正分界线是:你的校验 80% 在客户端还是服务端?字段数超过 15 个、有联动、要即时校验,react-hook-form 的 uncontrolled 性能优势仍然明显。我实测 12 个字段表单,RHF 版本每个按键只重渲染当前字段(用 Controller 的 2 个字段除外);useActionState 版本提交时整个客户端组件重渲染。bundle 方面,RHF+zod+resolvers 在我这个路由 First Load JS 里占 34.8KB gzip,useActionState 是 React 内置 0 额外 gzip,但 Server Actions 的运行时和 Next.js 框架代码不算在内。如果你用 Vite + React 19,没有 Server Actions,useActionState 只能配客户端 action,省不了多少。

教程步骤。环境:Node 20.18.1,Next.js 15.1.3 App Router,React 19.0.0,TypeScript 5.7.2,zod 3.24.1。第一步,写 Server Action:'use server',用 zod safeParse,失败返回 { ok: false, errors: parsed.error.flatten().fieldErrors },成功 revalidatePath('/merchant') 后 redirect('/merchant/done')。第二步,客户端组件用 useActionState(submitShop, initialState),form 的 action 直接给 formAction。第三步,提交按钮单独抽成子组件,用 useFormStatus 拿 pending。第四步,错误用 state.errors?.shopName?.[0] 显示。代码大概长这样:

图片

'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'

const schema = z.object({
  shopName: z.string().min(2, '店铺名至少 2 个字'),
  licenseNo: z.string().regex(/^[A-Z0-9]{15}$/, '营业执照编号 15 位'),
  contact: z.string().email('邮箱格式不对'),
})



![图片](http://img2.baidu.com/it/u=3595572893,3746907889&fm=253&fmt=auto&app=138&f=JPEG?w=950&h=399)


export type FormState = {
  ok: boolean
  message?: string
  errors?: Record<string, string[]>
}

export async function submitShop(prevState: FormState, formData: FormData): Promise<FormState> {
  const raw = Object.fromEntries(formData)
  const parsed = schema.safeParse(raw)
  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten().fieldErrors }
  }
  await new Promise(r => setTimeout(r, 380))
  redirect('/merchant/done')
}
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { submitShop, type FormState } from './action'



![图片](http://img0.baidu.com/it/u=579851355,4096421388&fm=253&fmt=auto&app=138&f=JPEG?w=684&h=358)


const initialState: FormState = { ok: false }

function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? '提交中...' : '提交'}</button>
}



![图片](http://img2.baidu.com/it/u=1124184096,4240195000&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=795)


export function ShopForm() {
  const [state, formAction] = useActionState(submitShop, initialState)
  return (
    <form action={formAction} className={'space-y-4'}>
      <input name={'shopName'} />
      {state.errors?.shopName && <p>{state.errors.shopName[0]}</p>}
      <input name={'licenseNo'} />
      {state.errors?.licenseNo && <p>{state.errors.licenseNo[0]}</p>}
      <input name={'contact'} />
      {state.errors?.contact && <p>{state.errors.contact[0]}</p>}
      <SubmitButton />
    </form>
  )
}

踩坑。坑 1:redirect 在 Server Action 里是抛错实现的,不能包在 try/catch 里。我第一版写 try { await db.insert(); redirect('/done') } catch (e) { return { ok: false, message: '失败' } },结果页面不跳,控制台报 NEXT_REDIRECT,我找了 40 分钟。坑 2:useFormStatus 必须在 <form> 的子组件里用,和 useActionState 同一个组件拿不到 pending。我第一次把 SubmitButton 直接写在 ShopForm 里,pending 永远 false,按钮永远不禁用。坑 3:useActionState 的 state 不会自动清空。成功 redirect 没事,如果 action 返回 { ok: true } 但留在当前页,上次的错误信息还在。输入框也不受控,不会重置。要重置得用 key 或 formRef.reset()。另外,Server Action 不是 API Route,抓包看到的是 Next-Action 头,不是 /api/submit,调试时 Chrome Network 面板不太直观。

图片

对比维度表。体积:RHF+zod 34.8KB gzip vs useActionState 0KB 额外,但 Next.js Server Actions 运行时无法单独计。渲染:RHF 默认非受控,50 字段表单每敲一个字只渲染当前字段;useActionState 提交和错误变化时整个表单组件重渲染。校验时机:RHF 有 onChange、onBlur、onSubmit、resolver;useActionState 原生只在提交时。渐进增强:useActionState + Server Action 在 JS 加载失败时仍可能提交(Next.js 15 支持),RHF 不行。服务端错误:useActionState 直接返回 state,RHF 要手动 setError('root.server')。缓存失效:Server Action 里要记得 revalidatePath 或 revalidateTag,RHF 提交后自己 router.refresh() 或 mutate。选型阈值:字段 <= 8、校验 80% 在服务端、提交后跳转,用 useActionState;字段 > 15、有联动、即时校验、数组字段,用 RHF;登录/注册/留言,useActionState 够;多步骤、国际化、复杂数组,RHF 的 useFieldArray 和 devtools 省命。

全新观点。React 19 Actions 真正改变的是数据提交边界:以前是客户端状态 -> fetch -> 手动同步;现在是 form action -> server function -> 返回 state。复杂度没有消失,只是从客户端搬到服务端。如果你的团队没有 Node 运行时、没有 Server Actions 安全边界(永远不要信任客户端校验)、没有缓存失效策略,那 useActionState 会变成更隐蔽的坑。还有,不要因为 0KB 额外依赖就选它。Server Action 默认 POST 到当前路由,和 API Route 相比,调试工具少,抓包看到的是 Next-Action 头,排查难。我最后的项目用了混合方案:简单表单用 useActionState,复杂商家入驻表单还是 RHF。代码行数不是唯一指标,团队里新人能不能 10 分钟看懂 action 和 state 的关系,才是。我那个 12 字段表单,最后 RHF 版本 118 行,useActionState 版本 76 行,但 useActionState 版本我多花了 2 小时填坑。值不值,看你的字段数和团队。

🏷️ 标签: