表单处理是 React 开发里永远绕不开的一环,从经典的受控组件到各种表单库,我前后换过好几套方案。直到 React 19 把 useActionState 正式推到前台,我才觉得这套写法才是表单场景该有的样子:把提交逻辑和状态更新收拢到一个 action 函数里,配合 form 的 action 属性,连手动管理 loading、保存上次输入、错误回显这些重复劳动都省了。这篇文章我打算从为什么需要它、核心机制拆解、完整实战落地、与其他 Hooks 的搭配、踩坑记录这五个维度聊聊,希望能帮你把这把利器顺顺当当收进工具箱。
如果你只是听过 useActionState 但还没正经用过,或者用过但总在「为什么 isPending 不生效」「action 里怎么访问表单值」这种细节上卡壳,这篇文章应该正好对症。文中所有代码都是完整可跑的示例,直接复制到项目里改改接口就能用。
1. 为什么表单场景需要 useActionState
1.1 传统表单方案的痛点
先用一个最典型的场景说话:登录表单。你大概率写过这样的代码,用 useState 存用户名和密码,提交时手动调接口,再用 setState 更新错误信息。
function LoginForm() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); async function handleSubmit(e) { e.preventDefault(); setLoading(true); setError(null); try { const result = await api.login({ username, password }); if (!result.ok) { setError(result.message); } } catch (err) { setError('网络异常,请稍后重试'); } finally { setLoading(false); } } return ( <form onSubmit={handleSubmit}> <input value={username} onChange={(e) => setUsername(e.target.value)} /> <input value={password} onChange={(e) => setPassword(e.target.value)} /> {error && <p className="error">{error}</p>} <button disabled={loading}> {loading ? '登录中...' : '登录'} </button> </form> ); }这套写法的缺点很明显。第一,状态和逻辑是散的:error 是 error、loading 是 loading、表单值又是另一套状态,字段一多 useState 就像一串糖葫芦,每次查问题都得在几个 useState 之间来回跳。第二,提交动作被硬塞进 onSubmit 的流程里,当你想复用这套逻辑做自动保存、重试、带参数提交时,函数签名根本伸展不开。第三,loading 状态完全靠手动 setLoading(true/false),只要有一个分支忘了触发 finally,按钮就会永远转圈,这种 bug 一旦出现还特别难复现,因为它是时序问题。
我并不是说传统写法一无是处,对于简单的纯前端查询表单,useState 加 handleSubmit 完全够用。但一旦涉及异步提交、校验回显、表单值回填、多个提交入口这些需求,手动维护状态的方式就会开始漏风,而且是不容易察觉的那种漏风。拿错误回显举例,用户填了十行资料,点提交发现手机号格式不对,这时候如果表单把十行全清空了,用户心态直接炸;但你要是为了不清空,又得把 values 也塞进 state,于是又多了几个 onChange 和 setState,组件瞬间就胖了一圈。
1.2 Form Action 带来的思路转变
useActionState 的核心思路,是把「提交动作」本身变成状态的唯一来源。你不用再单独定义 error state、loading state、上一次输入快照,只需要写一个 action 函数,它的返回值就是下次渲染需要的 state。这个概念最早是从 React 的 form action 能力延伸出来的,当你给<form action={fn}>传一个函数时,React 会在原生提交事件上做拦截,然后把这个 form 里所有带 name 属性的字段自动收集成 FormData,再交给你的函数处理。
useActionState 在这个基础上又加了一层状态管理。它把 action 函数包装一下,让 action 接收两个关键参数:第一个是上一次返回的 state,第二个是 FormData。你可以在 action 内部基于旧状态生成新状态,也可以在首次调用时使用你传入的 initialState。hook 返回三个值:最新的 state、包装后的 formAction、以及一个 isPending 布尔值,后者会在 action 执行期间自动变化。
这个模式最让我心动的地方,是它和 HTML 原生表单能力的对齐。原生 form 提交本来就不需要你手动把 input 值一个一个塞进对象,FormData 直接替你收集好;而 useActionState 等于把「收集数据 -> 处理异步 -> 回显状态」这条链路完整接管了,你只需要关心 action 函数内部怎么写。对于从原生开发转过来的朋友,这个心智模型其实比「受控组件 + onChange」更接近 HTML 原本的工作方式。
1.3 在什么场景下收益最大
不是所有表单都适合 useActionState。在我实际用过一段时间后,总结出三类收益最明显的场景。
第一类是异步提交型表单,典型代表是登录、注册、资料保存、评论发布。它们都有一个共同特点:用户动作触发网络请求,请求期间需要 loading,请求结束需要反馈成功或失败,失败时最好还能保留用户输入。这正是 useActionState 的甜区。
第二类是存在多个提交入口的表单。比如一个页面里既有「保存草稿」又有「正式提交」,两个按钮都会把当前表单内容交给不同的逻辑。传统写法你得把同一套表单数据在多个 handler 里传递,而 useActionState 可以把不同动作写成不同的 action,每个按钮绑定各自的 formAction,互不干扰。
第三类是逻辑需要被异步事件触发的表单,比如自动保存定时器、路由离开前的保存提示。因为动作本身是独立的、可捕获的函数,你可以随时拿到它去调用,而不像传统 onSubmit 那样必须依附在组件的事件上。这三种场景下,useActionState 的收益是实打实的,而不是换汤不换药。
2. useActionState 核心机制逐项拆解
2.1 参数签名到底在说什么
先看完整的函数签名:
const [state, formAction, isPending] = useActionState(action, initialState, permalink?);第一个参数 action 是提交处理函数,它的标准签名是(prevState, formData) => newState。注意,第一个参数是上一轮 state,不是 FormData,这个顺序特别容易搞反。我刚上手的时候,在函数里写成(formData, prevState),结果所有表单值都解析不出来,浪费了好一会儿。React 这么设计是刻意的:它让你天然获得「上一次提交结果」的访问权,这正是错误回显、值回填、增量合并的基础。
第二个参数 initialState 是初始状态。action 没被调用、或者首次渲染时,state 就是这个值。实战中我一般传一个空对象,比如{ errors: {}, values: {}, success: false },避免渲染层对 null 做一堆判断。
第三个参数 permalink 是可选参数,它只对服务端 Action 有意义。简单理解,它允许你在提交后让 URL 带上一个 hash,浏览器刷新或分享时,React 能根据这个 hash 恢复对应的表单状态。客户端表单用不上,但如果你的项目用了服务端渲染,建议看一眼官方文档里的说明,因为这是客户端表单 API 里一个比较特殊的入口。
2.2 返回值三个成员的实战含义
第一个返回值 state 是当前状态,完全由 action 的返回值决定。渲染层可以自由读取它,比如展示提交错误信息、回填表单值、判断是否弹出成功提示。这里有个隐含约定:action 的返回值必须是一个完整的新状态,最好是不可变创建的新对象,而不是改完再返回同一个引用,这个坑在第 5 部分会细讲。
第二个返回值 formAction 是传给<form action>的包装函数。你在组件里写的<form action={formAction}>,React 提交时调用的实际已经是内部包装过的版本,它会做几件事:把表单数据序列化成 FormData、调用你的 action、跟踪 transition 状态、更新 state。如果你把 formAction 传给了别的组件或者别的元素,它也能正常工作,只要最终它被当作 form 的 action 使用。
第三个返回值 isPending 表示当前是否处于提交等待中。它由 React 在内部通过 transition 机制管理,action 进入执行状态时变 true,结束后变 false。这个设计解决了手动 loading 的老问题:不需要你在 try finally 里小心翼翼地兜底,只要 action 在执行,UI 层的按钮禁用、文案切换、遮罩层都会自动响应。有一点要注意,isPending 的生命周期是跟着 action 走的,如果 action 内部抛了异常,isPending 依然会走完整个过程,错误需要自己在 action 内部 catch 后转成 state 返回。
2.3 它和 useState、useReducer 有什么边界
很多人会问,既然已经有 useState、useReducer,为什么还要一个新的 hook。我的理解是:useState 管的是「组件内部任意状态」,useReducer 管的是「通过 dispatch 动作更新状态」,而 useActionState 管的是「表单提交这条特定链路」。它不是要替代前者,而是把表单场景中最繁琐、最容易写错的部分整合成了固定模式。
useState 依旧适合存 UI 开关、展开状态、临时标记;useReducer 依旧适合复杂状态机、购物车逻辑、历史记录这类场景。但它们处理表单异步提交时,都需要你额外管理加载周期、请求竞态、错误恢复,而这些正是 useActionState 内置的。换个角度说,useActionState 更像是一把专用刀,刀锋对准的就是「提交 -> 等待 -> 回显」这个循环,你非要用它去做通用状态管理,反而会别扭。
3. 实战:从零搭建一个完整表单
3.1 最小可用版:登录表单
我们直接来一个能跑的最小例子。假设没有脚手架,只有 React 和一段模拟接口。
import { useActionState } from 'react'; async function fakeLogin({ username, password }) { await new Promise((resolve) => setTimeout(resolve, 800)); if (username === 'admin' && password === '123456') { return { ok: true }; } return { ok: false, message: '用户名或密码错误' }; } function loginAction(prevState, formData) { const username = formData.get('username'); const password = formData.get('password'); return fakeLogin({ username, password }); } function LoginForm() { const [state, formAction, isPending] = useActionState(loginAction, { ok: false, message: '', }); return ( <form action={formAction} className="login-form"> <div> <label htmlFor="username">用户名</label> <input id="username" name="username" type="text" /> </div> <div> <label htmlFor="password">密码</label> <input id="password" name="password" type="password" /> </div> {state.message && <p className="error">{state.message}</p>} <button type="submit" disabled={isPending}> {isPending ? '登录中...' : '登录'} </button> </form> ); }几个细节说一下。第一,fakeLogin 返回 Promise,但 loginAction 本身没写成 async,因为在 action 内直接 return Promise 也是允许的,React 会把 Promise 的结算值当作新 state。当然你也可以把 loginAction 写成 async,多一层包装而已,不影响功能。第二,我没有给 input 写 value/onChange,表单完全是非受控模式,输入框自己维护内部值,提交时通过formData.get('username')取值。这个设计不是偷懒,而是 useActionState 的推荐玩法:它希望你把读值推迟到提交动作里,避免每敲一个字母就触发一次组件 render。第三,prevState 在基础版里没用到,看起来有点浪费,别急,下一部分就让它发挥作用。
3.2 进阶:校验失败后保留用户输入
真实的表单不能一错就把用户敲的东西全清空。传统方案需要额外 state 存 values,再用 value 加 onChange 控制 input。useActionState 的解法,是在 action 返回的状态里带回 values,渲染时把这些值填回 input 的 defaultValue。
const initialValues = { username: '', city: '', phone: '' }; async function submitProfile(prevState, formData) { const values = { username: formData.get('username') ?? '', city: formData.get('city') ?? '', phone: formData.get('phone') ?? '', }; const errors = {}; if (values.username.trim().length < 2) { errors.username = '用户名至少两个字符'; } if (!/^1\d{10}$/.test(values.phone)) { errors.phone = '手机号格式不正确'; } if (Object.keys(errors).length > 0) { return { errors, values }; } const result = await api.saveProfile(values); if (!result.ok) { return { errors: { server: result.message }, values }; } return { errors: {}, values, successMessage: '保存成功' }; } function ProfileForm() { const [state, formAction, isPending] = useActionState(submitProfile, { errors: {}, values: initialValues, successMessage: '', }); return ( <form action={formAction}> <div> <label>用户名</label> <input name="username" defaultValue={state.values.username} aria-invalid={Boolean(state.errors.username)} /> {state.errors.username && ( <p className="field-error">{state.errors.username}</p> )} </div> <div> <label>手机号</label> <input name="phone" defaultValue={state.values.phone} aria-invalid={Boolean(state.errors.phone)} /> {state.errors.phone && ( <p className="field-error">{state.errors.phone}</p> )} </div> {state.errors.server && ( <p className="error">{state.errors.server}</p> )} {state.successMessage && <p className="success">{state.successMessage}</p>} <button disabled={isPending}> {isPending ? '保存中...' : '保存'} </button> </form> ); }这里的关键是「错误信息的回显」和「输入值的保留」都在一个 action 里完成。校验失败时返回{ errors, values },其中 values 就是本次提交的原始值;React 更新 state 后组件重渲染,defaultValue 会填回用户上次输入的内容。输入不丢,错误也一目了然,而且完全没有受控组件的逐字渲染开销。
有一个容易踩的坑:如果你把 input 改成受控,也就是同时传 value 和 onChange,再配合 useActionState,会发现自己陷入两难。一方面 action 已经提供了默认值回填能力,另一方面受控逻辑又在每个按键时强制更新状态,两者叠在一起轻则多余渲染,重则出现输入框光标跳动。我的建议是:表单场景优先非受控,只有在需要联动校验、动态清零等强交互时才考虑受控。这里的联动校验指的是,比如手机号和验证码互相影响、某个字段改变要清空另一字段,这种场景受控仍然更方便。
3.3 复杂场景:多字段收集与防重复提交
再往前一步,看一个更贴近业务的场景:购物结算表单,包含姓名、地址、优惠码、多个商品条目。字段一多,逐个formData.get又长又容易漏,我会先写一个小工具函数:
function collectFields(formData, keys) { const result = {}; for (const key of keys) { result[key] = formData.get(key) ?? ''; } return result; }动态条目是另一个麻烦。你可以用同一个 name 加索引,比如name="item-0-sku"、name="item-1-sku",然后在 action 里遍历 keys 来收集;或者更简单,直接用formData.getAll('sku'),前提是每个条目的 input 的 name 都是完全相同的字符串。注意,FormData 的 getAll 只对完全同名的字段生效,如果你用items[0].skuId这种带索引的命名方式,getAll 不会帮你合并,得自己解析 key 文本,非常容易出错。我的建议是:能不用索引命名就不用,用同名 + 行号前缀反而是最省心的。
防重复提交也值得单独说。isPending 可以直接 disable 按钮,但如果你在 action 里还要调第三方接口,建议再加一层兜底保护,用 useRef 标记请求进行中,防止快速连点或回车导致双重提交。我遇到过不止一次:按钮明明禁用了,用户按回车还是能触发提交,因为回车键触发的提交路径绕过了按钮的 disabled 视觉状态。所以 action 内部再兜底一次比较稳妥。
function CheckoutForm() { const submittingRef = useRef(false); const [state, formAction, isPending] = useActionState( async (prevState, formData) => { if (submittingRef.current) { return prevState; } submittingRef.current = true; try { const values = collectFields(formData, ['name', 'address', 'promo']); const result = await api.checkout(values); if (!result.ok) { return { error: result.message, values }; } return { error: '', success: true, values }; } catch (err) { return { error: '提交失败,请稍后重试', values }; } finally { submittingRef.current = false; } }, { error: '', success: false, values: initialValues } ); // 渲染逻辑省略,和前面的 ProfileForm 类似 }在 finally 里把 ref 复位,这样下一个提交能正常开始。使用 useRef 而不是模块级变量,是为了避免多个表单实例之间互相影响,也方便组件卸载时自然丢弃。
4. 搭配 React 19 周边 API 的实战组合
4.1 useFormStatus:让按钮子组件自动感知 pending
useActionState 的 isPending 只在调用该 hook 的组件里可见。但实际项目中,提交按钮往往藏在一个独立的子组件里,比如一个封装了 loading 效果和禁用样式的 SubmitButton。这时候如果层层传 props,代码会很难看。React 19 给出的解法是 useFormStatus。
function SubmitButton({ children }) { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? '提交中...' : children} </button> ); }使用方式特别简单:只要 SubmitButton 是<form>元素的后代组件,它就能直接读到当前 form 的提交状态,不需要接收任何 props。这个组件可以塞进表单的任何角落,也可以用于进度条、提交提示文案等。最近处没有 form 时会拿到 pending 为 false 的默认值,所以要确保它确实在 form 内部,这是它的唯一限制。
实践中我会把 useFormStatus 和 useActionState 搭配使用:useActionState 负责整体状态流,useFormStatus 负责让子组件无感知地响应提交状态。如果你的项目表单里有多个按钮,比如「保存草稿」和「正式发布」在同一个 form 里分别提交,useFormStatus 的 pending 会对应当前触发的那个 action,体验上非常顺滑。
4.2 useOptimistic:提交瞬间先看到结果
用户体验更好的方案是「乐观更新」:提交接口还没返回,界面先展示成功结果,失败再回滚。React 19 的 useOptimistic 和 useActionState 是天然的搭档,一个负责提交介质,一个负责即时反馈。
function CommentBox({ comments }) { const [optimisticComments, addOptimisticComment] = useOptimistic( comments ); const [state, formAction, isPending] = useActionState( async (prevState, formData) => { const text = formData.get('text'); addOptimisticComment({ id: Date.now(), text, pending: true }); const result = await api.postComment(text); if (!result.ok) { return { error: result.message }; } return { error: '' }; }, { error: '' } ); return ( <form action={formAction}> <input name="text" /> <ul> {optimisticComments.map((comment) => ( <li key={comment.id} className={comment.pending ? 'dim' : ''}> {comment.text} </li> ))} </ul> </form> ); }useOptimistic 的返回值里,optimisticComments 是「真实列表 + 乐观添加的临时项」的合并结果。提交瞬间调用 addOptimisticComment,用户立刻看到自己刚发的评论,虽然它可能还处于 pending 状态,但视觉上已经出来了。等接口返回成功,真实列表更新后,乐观项会被 React 自动清理。如果接口失败,乐观项也会被收回,error 信息展示给用户。
但要注意,乐观更新成立的前提是接口失败率足够低、且失败后的回滚逻辑足够干净。用户看到评论发出去了,下一秒又消失,这种体验比一开始就不给反馈还伤。生产环境里我会在乐观数据上加一个明显的 pending 视觉样式,同时把失败兜底文案写清楚,让用户知道发生了什么。
4.3 服务端 Action 场景的注意事项
如果你的项目用了服务端渲染框架,useActionState 的价值会进一步放大,因为 action 可以直接写服务端逻辑,prevState 在多次请求之间也能传递。这意味着校验逻辑不用在客户端和服务端各写一遍,表单状态在导航刷新后也能尽量保留。
但服务端 Action 有一个硬性约束:返回值必须能被序列化。这意味着不要在 state 里放 Date 对象、Map、Set、函数之类的东西,否则部署到服务端渲染环境后浏览器会报「不能序列化」的错误。这个坑很隐蔽,本地开发有时候一切正常,因为部分环境会宽容处理,但到了线上就崩。我自己的习惯是,state 一律用普通对象和字符串,时间戳用数字,复杂结构在 action 内部处理掉,绝不外泄。
另外,服务端场景下 useActionState 的第三参数 permalink 就派上用场了。它可以在提交后给 URL 追加 hash,浏览器刷新或者分享时 React 能根据 hash 恢复表单状态。这个能力在「提交失败后刷新页面不丢输入」的场景里非常好用,唯一的注意点是 permalink 要稳定生成,不要每次都随机串。
5. 常见问题排查与避坑速查
5.1 isPending 不更新的排查顺序
这个问题我在社区里见到的频次最高,排查顺序我一般是这样。
第一步,确认 React 版本是否支持。useActionState 在 React 19 正式版里已经稳定,React 18 只有实验版本能用。如果你的依赖里写的是 18.2.0,直接用 useActionState 大概率会报 undefined,因为它在 React 18 的稳定导出里根本不存在。
第二步,确认 formAction 真的传给了<form action>。有人会把 formAction 传给 Form 组件的 onSubmit,或者干脆绑在 button 上,那 isPending 自然一直是 false。组件传参不对的时候,action 可能压根没被触发。验证方法很简单,在 action 函数第一行加一个 console.log,看控制台有没有输出。
第三步,确认 action 是异步的。如果 action 是纯同步函数,React 可能在同一个事件循环里就把 transition 结束掉,你肉眼几乎看不到 isPending 变 true 的过程。这也是为什么我建议所有真实提交动作都至少包一层 Promise 或网络请求,让异步边界清晰起来。
5.2 state 引用不变导致 UI 不刷新
action 正常执行、返回也正常,但 UI 迟迟不刷新,这种问题九成出在「返回值的引用没有变化」上。React 的 state 更新比较用的是 Object.is,如果你每次返回同一个数组引用,只是往里面 push,React 会认为状态没变,直接跳过重新渲染。
// 错误示范:直接修改 prevState 之后又返回它 function badAction(prevState, formData) { prevState.list.push({ text: formData.get('text') }); return prevState; // 引用没变,UI 不更新 }正确做法是每次返回一个新的对象或数组:
// 正确示范:创建新引用 function goodAction(prevState, formData) { return { ...prevState, list: [...prevState.list, { text: formData.get('text') }], }; }这条经验也解释了为什么 action 里不要依赖副作用修改外部变量。如果你改了外部数组,state 看起来变了,但 React 因为引用相同直接跳过渲染,就会出现「明明改了却不刷新」的灵异事件。排查这类问题最快的办法是给返回值临时加一个版本号字段,每次递增,看 UI 是否跟着变,变了就说明是引用问题。
5.3 defaultValue 不回填的真正原因
如果你用 defaultValue 回填,会发现在 action 返回新 state 后,input 的 defaultValue 变化并不会触发 UI 更新。这是 React 故意设计的:defaultValue 只在初始挂载时生效,之后修改它不会影响已挂载 input 的值。这是很多人的知识盲区,因为受控组件的 value 是可变的,容易让人误以为 defaultValue 也一样。
这时候的正确姿势,是给 input 加一个 key,用 state 里能代表新数据的字段作为 key 值:
<input key={state.version} name="username" defaultValue={state.values.username} />每次提交返回的 version 递增,React 就会重新挂载这个 input,defaultValue 随之生效。代价是 input 会短暂失去焦点,所以这个方法更适合提交结束后的回填场景,而不是逐字输入的场景。如果不想用 key 重挂载,也可以退回去用受控 value,但那又回到了手动管理表单状态的老路上,需要按场景权衡。
5.4 其他高频问题速查表
| 现象 | 常见原因 | 解决方案 |
|---|---|---|
| isPending 始终 false | 版本不支持或 formAction 没绑定到 form action 属性 | 升级 React 19,检查绑定,action 里打日志验证 |
| state 不刷新 UI | 返回了相同引用 | 使用展开语法创建新对象/数组,或加版本号字段 |
| defaultValue 不回填 | defaultValue 只在挂载时生效 | 配合 key 重挂载 input |
| 表单重复提交 | 回车绕过 disabled 按钮 | action 内用 useRef 做兜底防重 |
| action 拿不到表单值 | input 缺少 name 属性 | 给所有输入项补上唯一的 name |
| 提交后 input 值不展示 | 忘在 state 里返回 values | action 返回值里带上本次输入快照 |
还有一个容易忽略的点:如果 action 抛出的异常没有被捕获,React 只会把异常丢给错误边界,state 不会更新成「错误」状态。所以 action 里一定要用 try catch 兜住网络异常和业务异常,最终以返回值的形式表达失败,而不是用 throw。这是 action 写法和传统函数写法一个很重要的区别。
6. 落地经验与个人体会
6.1 什么时候该用,什么时候别硬套
我的个人经验是:凡是涉及「异步提交 + 结果回显」的表单,都值得优先试试 useActionState。登录注册、资料编辑、评论发布、订单创建,这类场景它会明显减少样板代码,也让状态流更清晰。因为提交、等待、错误处理被绑成了一个整体,代码评审的时候一眼就能看到每个表单的完整业务逻辑。
但纯本地交互、不需要异步处理的表单,比如一个复杂的筛选条件面板,就不太适合。筛选面板的价值在瞬时响应,每改一下都需要同步更新状态,而 useActionState 的设计目标是提交链路,硬套反而别扭。这种场景老老实实用受控组件加 useState 更舒服。
另一个不建议的场景,是校验联动极其复杂的巨型表单,比如几十个字段、互相依赖、动态增删行的税务申报表。这种情况下 useActionState 的返回值管理会撑不住,不如引入专门的表单库来管校验和联动。工具选型不是越新越好,而是越适合越好,这一点在工程上永远成立。
6.2 迁移旧表单的四步流程
如果你想把现有项目里的登录表单迁移到 useActionState,我建议按这个顺序来,能最大限度降低风险。
第一步,把原有的 handleSubmit 拆成 action 函数。把原本的 e.preventDefault、收集字段、调用接口、设置状态的逻辑整体挪进 action,返回一个包含 error 和 success 的对象。这一步只做挪移,不改行为。
第二步,删掉 loading state。用 isPending 替换所有 setLoading(true/false) 的调用,把按钮的 disabled、文案、遮罩层改成读 isPending。检查一遍,确保没有任何地方还在 setLoading,因为 loading state 已经不存在了。
第三步,处理错误提示和值回填。把原来的 error state 合并进 action 的返回值;如果要保留用户输入,再用 defaultValue 加 key 的方式回填。原来的错误提示 JSX 可以保留,只是数据来源从 useState 变成 state.errors。
第四步,跑一遍全流程,重点测三个东西:提交成功后状态是否归位、提交失败后错误是否展示、提交中按钮是否禁用。这三项过了,迁移基本就稳了。如果你有单元测试,把 action 函数单独拿出来测会更省心,因为它已经是一个纯函数了。
我在一个实际项目里把三个表单迁移成 useActionState,整体改动量并不大,删掉的 loading state 和重复错误处理代码比新增的 action 逻辑还多。迁移之后最直观的感受是,每个表单的处理逻辑变成了一块独立的、可以测试的函数,不再和组件生命周期纠缠在一起。
6.3 关于这套模式的深层体会
最后聊聊我对 useActionState 的整体看法。很多人第一次看它,会以为只是 useState 的替代品,但我觉得它的价值更接近「表单业务逻辑的标准化」。它强制你把提交处理写成一个函数式的 action:输入是旧状态和 FormData,输出是新状态。这个约束本身就是在帮你减少 bug——异步竞态、loading 漏重置、错误状态残留,这些问题的根源都是状态管理分散,而 useActionState 从结构上就把这些坑堵住了。
在实际使用中,我还发现它和单元测试配合得特别好。action 是一个普通函数,不需要渲染组件就能直接调用,传两个参数就能验证返回结果符不符合预期。这使得表单逻辑的测试成本大幅下降,对于业务复杂、表单多的项目来说,这是一个容易被低估的好处。我甚至会把 action 单独放在一个文件里导出,UI 组件只关心渲染,这样职责边界更清楚。
如果你的项目已经在使用 React 19,或者正在计划升级,我建议在下一个表单需求里先拿 useActionState 做一个最小可用版本,感受一下「把提交逻辑放进一个函数」到底是怎样一种体验。第一次写的时候可能会觉得函数签名别扭,但跑通两三个场景之后,你会开始理解为什么它会被社区称作表单处理中最锋利的那把剑。