上周三凌晨 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('邮箱格式不对'),
})

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'

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

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 小时填坑。值不值,看你的字段数和团队。