React 19 表单与 Actions:别再把提交状态全塞进 useState
React 19 最值得关注的变化之一,是它开始更认真地处理“异步交互”这件事。过去我们写表单时,经常会手动维护一堆状态:isSubmitting、error、success、formValues、serverMessage。需求一复杂,组件就会从一个表单变成一个状态仓库。
2024 年我重新看 React 表单时,最明显的感受是:React 不再只把表单当作一组受控输入,而是把它当作一次完整的用户动作。输入、提交、等待、失败、成功、回滚,这些都应该有更清楚的表达方式。
时间点要先摆清楚:2024-06 写到这里时,React 19 还处在 RC 阶段,官方还没有发布正式版。所以这更像是一份基于 RC 能力的实践记录,而不是稳定版发布后的迁移清单。真正落地时,还是得看项目锁定的 React 版本、react-dom 版本和类型包是否一起跟上。
React 19 里的 Actions、useActionState、useFormStatus、useOptimistic 就是在解决这个问题。
传统写法的问题
先看一个很常见的登录表单:
1function LoginForm() { 2 const [email, setEmail] = useState('') 3 const [password, setPassword] = useState('') 4 const [loading, setLoading] = useState(false) 5 const [error, setError] = useState('') 6 async function handleSubmit(event) { 7 event.preventDefault() 8 setLoading(true) 9 setError('') 10 try { 11 await login({ email, password }) 12 } catch (error) { 13 setError(error.message || '登录失败') 14 } finally { 15 setLoading(false) 16 } 17 } 18 return ( 19 <form onSubmit={handleSubmit}> 20 <input value={email} onChange={(event) => setEmail(event.target.value)} /> 21 <input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /> 22 {error ? <p>{error}</p> : null} 23 <button disabled={loading}>{loading ? '提交中' : '登录'}</button> 24 </form> 25 ) 26}
这段代码没有错,我自己也写了好几年。但它有几个长期维护问题。
第一,提交状态和业务状态混在组件里。表单越复杂,useState 越多,后面很难判断哪个状态属于输入,哪个状态属于请求,哪个状态属于服务端返回。我接手过一个收银台表单,光顶部就声明了十一个 useState,其中三个还互相依赖——改一个忘了同步另一个,是那段时间最常见的 bug。
第二,错误处理很分散。每个表单都自己 try...catch,项目里很容易出现十几种错误提示风格。有人用 alert,有人用 toast,有人在输入框下面红字,还有人直接 console.error 完事,用户什么都看不到。
第三,用户连续点击、网络慢、页面切走、接口超时这些异常情况都需要手动处理。简单表单还能忍,复杂表单会非常啰嗦。最典型的是双击提交:你以为 disabled={loading} 就够了,但 setLoading(true) 是异步的,在它生效之前用户的第二次点击已经进来了,订单就被提交了两次。我们最后是靠后端做幂等才兜住的,前端这层其实一直是漏的。
还有一个隐蔽的坑:finally 里的 setLoading(false)。如果用户在请求还没回来时就跳走了,组件已经卸载,React 17 及更早版本里经常会看到 "Can't perform a React state update on an unmounted component" 这类警告;React 18 以后这条警告不再作为主要信号出现,但状态本身的逻辑混乱并没有消失,只是更容易被忽略。
Actions 的思路
React 19 的 Actions 可以把“提交动作”作为一个异步函数来处理。它强调的是动作本身,而不是按钮点击之后手动维护一堆状态。
一个简化例子:
1async function submitProfile(formData) { 2 const name = String(formData.get('name') || '').trim() 3 if (!name) { 4 return { ok: false, message: '请输入昵称' } 5 } 6 await updateProfile({ name }) 7 return { ok: true, message: '保存成功' } 8}
表单提交时,formData 会自然带上输入字段。这个模型有一个很大的好处:代码更接近浏览器原生表单,而不是一上来就把所有输入都变成受控状态。
这里有个我一开始没意识到的点:把这个函数挂到 <form action={submitProfile}> 上时,React 会自动处理 event.preventDefault(),并且在异步函数执行期间把表单标记成 pending 状态。也就是说,过去要靠 setLoading(true/false) 手动圈出来的那段"提交中",现在是 React 替你管的,它知道这个 action 什么时候开始、什么时候结束。这正是 useFormStatus 能拿到 pending 的底层依据。
另一个容易踩的点:action 里读 formData.get('name') 拿到的可能是 null(字段不存在)或者 File(如果是文件输入),所以我习惯统一 String(formData.get('name') || '') 再 .trim(),不然某些边界下会直接抛 TypeError。
并不是说受控组件不好,而是它不应该成为唯一选择。很多表单只是提交时读取一次值,并不需要每次输入都触发 React 状态更新。我量过一个有二十多个字段的配置页,改成非受控之后,单次输入的渲染次数从整页重渲染降到了零——输入框的值压根没进 React,自然也不会触发 reconcile。
往下挖一层:action 到底是怎么被"管"起来的
我一开始把 Actions 当成"React 帮我写了 try/finally 的语法糖",直到一次排查 pending 状态不消失的 bug,才被迫去看了实现。Actions 的 pending 管理本质上是建立在 transition 之上的。<form action={fn}> 提交时,React 内部大致做了三件事:调用 startTransition、把你的异步函数包进去、在 transition 期间把表单关联的状态标记为 pending。useFormStatus 读的 pending,和 useActionState 返回的 isPending,底层都是问"当前有没有挂在这个作用域上的 transition 还没结束"。
这就解释了一个我当时怎么都想不通的现象:我在 action 里 await 了一个 fetch,但中途又 setState 触发了另一次更新,结果 pending 状态提前掉了。原因是 transition 的"结束"是以异步函数返回的那个 Promise resolve 为准的,如果你在函数体里提前 return(哪怕后面还有没 await 的 Promise 在跑),React 认为这个动作已经完成,pending 就置回 false 了。换句话说,React 跟踪的是函数的返回时机,不是你脑子里"这件事干完了"的时机。
1// 错误:fire-and-forget,pending 会立刻消失 2async function submit(prev, formData) { 3 saveDraft(formData) // 漏了 await,这个 Promise React 根本不知道 4 return { ok: true } // 函数马上返回,transition 结束,pending 直接没了 5} 6 7// 正确:把所有需要纳入 pending 的异步都 await 掉 8async function submit(prev, formData) { 9 await saveDraft(formData) 10 return { ok: true } 11}
理解这点之后,很多"pending 闪一下就没了""按钮还没转完就能点了"的灵异问题都能自圆其说——基本都是某处漏了 await。
useActionState:把提交结果收回来
useActionState 适合处理“提交后需要展示结果”的表单。
1import { useActionState } from 'react' 2 3const initialState = { ok: false, message: '' } 4 5async function saveProfile(prevState, formData) { 6 const name = String(formData.get('name') || '').trim() 7 if (name.length < 2) { 8 return { ok: false, message: '昵称至少需要 2 个字符' } 9 } 10 try { 11 await updateProfile({ name }) 12 return { ok: true, message: '保存成功' } 13 } catch (error) { 14 return { ok: false, message: normalizeError(error).message } 15 } 16} 17 18export default function ProfileForm() { 19 const [state, formAction] = useActionState(saveProfile, initialState) 20 return ( 21 <form action={formAction}> 22 <input name="name" placeholder="昵称" /> 23 {state.message ? <p>{state.message}</p> : null} 24 <button>保存</button> 25 </form> 26 ) 27}
这里有几个细节值得注意。
第一,提交函数返回的是 UI 需要展示的状态,而不是直接在函数里操作组件状态。
第二,校验逻辑可以放在 action 里,提交结果以统一结构返回。成功和失败都走同一个出口。
第三,表单字段通过 name 和 FormData 传递,减少了不必要的受控状态。
useActionState 其实返回三个值,文档里容易被忽略第三个:
1const [state, formAction, isPending] = useActionState(saveProfile, initialState)
这个 isPending 和 useFormStatus().pending 的区别在于作用域。isPending 是这个 action 维度的,你在持有它的组件里就能用;而 useFormStatus 必须放在 <form> 的子组件里,读的是所在表单的提交状态。简单设置项我就直接用 isPending,省得为了一个按钮再拆个子组件。
还有个真实踩的坑:提交失败时,非受控输入框里用户填的内容会不会被清掉?答案是不会——因为值在 DOM 里,React 重渲染只是更新了 state.message,没碰输入框。但如果你给输入框加了 defaultValue={state.name} 想做"回填",反而会出问题,因为 defaultValue 只在挂载时生效,后续更新无效。要做提交失败后保留并回显,得把这些值也一起从 action 返回,配合受控或者 key 来处理,这是非受控方案里最别扭的一块。
我的经验是:如果输入过程不需要实时联动,就不要急着用 useState 管每个字段。把表单值留在 DOM 里,提交时再读取,代码会轻很多。但一旦涉及"提交失败后要把用户填的内容连同校验错误一起回显",就老老实实把那几个字段也纳入 action 的返回结构,别硬扛。
顺带提一句,action 函数在提交期间整个表单的输入会被 React 临时设为不可编辑(防止你在提交途中又改了值导致数据不一致),这个行为第一次看到会以为是 bug,其实是有意为之的。
useFormStatus:提交按钮不必知道整个表单
传统写法里,按钮是否 loading 通常由父组件传入:
1<SubmitButton loading={loading} />
表单复杂后,按钮组件会被迫知道父组件的提交状态。useFormStatus 可以让按钮从所在表单上下文中读取状态。
1import { useFormStatus } from 'react-dom' 2 3function SubmitButton() { 4 const { pending } = useFormStatus() 5 return <button disabled={pending}>{pending ? '保存中...' : '保存'}</button> 6} 7 8export default function ProfileForm() { 9 const [state, formAction] = useActionState(saveProfile, initialState) 10 return ( 11 <form action={formAction}> 12 <input name="name" placeholder="昵称" /> 13 {state.message ? <p>{state.message}</p> : null} 14 <SubmitButton /> 15 </form> 16 ) 17}
这个变化看起来小,但对组件拆分很有帮助。提交按钮不再需要从父组件接收 loading,也不需要父组件为了按钮维护额外状态。
我第一次用 useFormStatus 时犯了个低级错误:把 useFormStatus() 写在了 ProfileForm 组件里,结果 pending 永远是 false。后来才搞明白,这个 Hook 读的是它"父级最近的那个 <form>"的状态,而 ProfileForm 本身就是渲染 <form> 的那一层,它和 <form> 是同级关系,拿不到。必须把按钮抽成独立子组件,让它成为 <form> 的后代才行。这个限制官方文档其实写了,但不亲自踩一遍不会记得。
useFormStatus 还能拿到 data、method、action 三个字段。data 就是正在提交的 FormData,我在一个多步骤表单里用它做过"提交途中显示用户刚填的值的摘要",比再开一份 state 同步方便。但要注意它只在 pending 期间有值,提交结束就变回 null,别拿它当数据源用。
useOptimistic:让反馈先发生
有些交互不应该等服务端返回后才更新界面。点赞、收藏、切换开关、追加评论,都适合做乐观更新。
1import { useOptimistic } from 'react' 2 3function CommentList({ comments, postId }) { 4 const [optimisticComments, addOptimisticComment] = useOptimistic( 5 comments, 6 (currentComments, newComment) => [...currentComments, { ...newComment, pending: true }] 7 ) 8 9 async function addComment(formData) { 10 const content = String(formData.get('content') || '').trim() 11 if (!content) return 12 addOptimisticComment({ id: `temp-${Date.now()}`, content }) 13 await createComment(postId, content) 14 } 15 16 return ( 17 <> 18 <ul> 19 {optimisticComments.map((comment) => ( 20 <li key={comment.id}> 21 {comment.content}{comment.pending ? '(发送中)' : ''} 22 </li> 23 ))} 24 </ul> 25 <form action={addComment}> 26 <input name="content" /> 27 <button>评论</button> 28 </form> 29 </> 30 ) 31}
这里有个 useOptimistic 设计上很妙、但容易让人困惑的点:你只管 addOptimisticComment 把临时数据加进去,完全不用手动回滚。它内部存的不是"乐观后的结果",而是一个 reducer 和一个基准值(你传进去的第一个参数 comments)。每次渲染,React 拿当前基准值,把这一轮 transition 里所有 addOptimistic 调用按顺序重放一遍,算出展示值;addComment 这个 action 一结束(无论成功还是抛错),重放的操作就被清空,展示值塌回基准值。所以失败回滚是"免费"的——前提是基准值本身没有被错误地并入这条临时数据。
我一开始没理解这个机制,画蛇添足地在 catch 里又写了一套 setState 移除临时项,结果出现了"先消失再闪回"的诡异闪烁,就是因为和 React 自己的回滚撞了。后来把那段删掉,干净多了。
这个"重放"模型也解释了一个更隐蔽的坑:基准值必须真的会变。如果 addOptimisticComment 之后成功了,却忘了同步更新上层的 comments(比如漏了 onCommentAdded 或没重新拉列表),transition 一结束乐观项被清空,基准值还是老的——评论就"发出去了又消失了",用户以为没成功又点一次,重复提交。这个 bug 在测试环境很难复现,因为本地 mock 接口返回快、列表刷新也快,肉眼看不到中间态;线上慢接口才暴露:
1// 反面教材:乐观加了,但真实数据源没更新 2async function addComment(formData) { 3 addOptimisticComment({ id: `temp-${Date.now()}`, content }) 4 await createComment(postId, content) 5 // 这里什么都没做 → transition 结束后塌回旧 comments,评论"消失" 6}
正确的做法是把返回值并入上层数据源(或者重新拉一次列表),让基准值和乐观项的清空同步发生;如果这一步本身还要走一次异步请求才能更新状态,中间会有一个乐观项先消失、真实项后出现的空窗闪烁,得把它也 await 掉再让基准值更新。
注意 addOptimisticComment 只能在某个 transition 或 action 里调用,直接在事件回调里裸调会报警告。挂在 <form action> 上天然满足这个条件,但如果你想在普通 onClick 里用,记得包一层 startTransition。
乐观更新的关键不是"先改 UI",而是要想清楚失败后怎么办。
对于点赞,失败后回滚通常可以接受。对于付款、删除、权限变更,就不能随便乐观更新。用户看到"已删除",结果服务端失败,反而更糟。一个真实的反面场景是批量操作:如果后端因为权限校验只拒了一半,前端却把整批都显示成功,用户会以为操作全部完成就直接关页面走了,等下次打开才发现数据对不上,而这类问题很难在测试环境提前暴露,因为测试数据通常权限一致、要么全过要么全拒。高风险操作应该等服务端确认再更新界面,而不是依赖乐观更新的"看起来完成了"。
我会按风险分层:
- 低风险:点赞、收藏、已读状态,可以乐观更新
- 中风险:评论、编辑标题,可以乐观追加但要显示 pending
- 高风险:支付、删除、权限、库存,不建议只靠乐观更新
错误处理要统一
无论用不用 React 19,API 错误都不应该每个表单各写一套。
我更推荐让请求层统一把错误转换成稳定结构:
1async function request(url, options) { 2 const response = await fetch(url, options) 3 const body = await response.json().catch(() => null) 4 5 if (!response.ok) { 6 throw normalizeApiError(response.status, body) 7 } 8 9 if (body && body.error) { 10 throw normalizeApiError(response.status, body) 11 } 12 13 return body 14} 15 16function normalizeApiError(status, body) { 17 const message = body?.error?.message || '请求失败,请稍后重试' 18 const err = new Error(message) 19 err.status = status 20 err.code = body?.error?.code || 'REQUEST_FAILED' 21 return err 22}
表单 action 里只关心展示结果:
1async function saveProfile(prevState, formData) { 2 try { 3 await request('/api/profile', { method: 'POST', body: formData }) 4 return { ok: true, message: '保存成功' } 5 } catch (error) { 6 return { ok: false, message: error.message } 7 } 8}
这样错误来源可以很多,但表单看到的结构始终一致。
有一个细节要提醒:useActionState 的 action 一旦抛出未捕获的异常,会冒泡到最近的 error boundary,整块 UI 可能直接挂掉。所以我习惯在 action 内部把所有错误都 catch 住转成返回值,只有真正"不该发生"的程序错误才让它抛出去。把"用户能看懂的失败"和"程序崩了"区分开,是这套写法能不能用得舒服的分水岭。
字段级校验怎么和 Actions 配合
整体提交结果好处理,但产品经常要"邮箱格式不对就标红那一栏"这种字段级反馈。Actions 本身不直接管这个,我的做法是让 action 返回一个 fieldErrors 映射:
1async function saveAccount(prevState, formData) { 2 const email = String(formData.get('email') || '').trim() 3 const fieldErrors = {} 4 5 if (!/^[^@]+@[^@]+\.[^@]+$/.test(email)) { 6 fieldErrors.email = '邮箱格式不正确' 7 } 8 9 if (Object.keys(fieldErrors).length > 0) { 10 return { ok: false, fieldErrors } 11 } 12 13 try { 14 await request('/api/account', { method: 'POST', body: formData }) 15 return { ok: true, fieldErrors: {} } 16 } catch (error) { 17 return { ok: false, message: error.message, fieldErrors: {} } 18 } 19} 20 21// 渲染时 22<input name="email" aria-invalid={!!state.fieldErrors?.email} /> 23{state.fieldErrors?.email && <span className="err">{state.fieldErrors.email}</span>}
这套对简单表单够用了。但一旦要做"边输入边校验""失焦校验""跨字段联动校验",Actions 就显得力不从心——它的反馈时机绑死在"提交"上,不会在输入过程中触发。这类需求我还是交给 React Hook Form 之类的库,它们的 mode: 'onBlur'、resolver 配 zod 这一整套,是 Actions 短期内补不上的。我现在的分界线很清楚:提交即校验的,用 Actions;需要实时校验体验的,上专门的表单库。
prevState 是跨提交累积状态的正确落点
useActionState 的 action 第一个参数 prevState 拿到的是上一次的返回值,不是 initialState。第一次提交是 initialState,第二次开始就是上一次 action 的产物了。这条规则决定了"跨提交累积的状态"该放在哪里。
一个典型需求是"连续提交失败 3 次后锁定按钮 30 秒防刷"。如果在 action 外部用一个闭包变量或模块级计数器去记失败次数,并发提交下计数很容易乱套——因为多次提交可能并发触发,外部可变变量没有天然的顺序保证。正确的落点是 prevState:
1const initialState = { ok: false, message: '', failCount: 0, lockedUntil: 0 } 2 3async function login(prevState, formData) { 4 // 防刷判断:用上一次返回的 lockedUntil 5 if (Date.now() < prevState.lockedUntil) { 6 return { ...prevState, message: '尝试过于频繁,请稍后再试' } 7 } 8 9 try { 10 await request('/api/login', { method: 'POST', body: formData }) 11 return { ok: true, message: '登录成功', failCount: 0, lockedUntil: 0 } 12 } catch (error) { 13 const failCount = prevState.failCount + 1 14 const lockedUntil = failCount >= 3 ? Date.now() + 30_000 : 0 15 return { 16 ok: false, 17 message: lockedUntil ? '失败次数过多,锁定 30 秒' : error.message, 18 failCount, 19 lockedUntil, 20 } 21 } 22}
把累积状态收进返回值后,并发问题自然消失了——因为 React 会串行处理同一个 formAction 的提交,每次都喂给你最新的 prevState,根本不需要外部可变变量。但凡是"和上一次提交相关"的状态,第一反应就该是 prevState,而不是组件外的闭包变量。排查这类问题时,在 action 入口打一行 console.log(prevState),能很直观地看到它在每次提交之间是怎么变化的,比光看代码推理更快确认自己对语义的理解是否正确。
把几个能力拼在一起
实际项目里,一个评论框通常是把 useActionState、useFormStatus、useOptimistic 和前面的统一错误处理拼在同一个组件里:提交按钮用 useFormStatus 读 pending,字段校验和错误结构走 useActionState,输入瞬间的反馈交给 useOptimistic。各自的写法前面章节都给过例子,这里不再重复贴一整段拼装代码,只强调两条容易漏掉、且前面没讲透的地方。第一,负责"把乐观状态转正"的那次数据刷新(比如重新拉列表的 refreshComments)必须真的同步更新了基准数据源,否则 action 一结束乐观项被清空,基准值还是旧的,评论会"发出去又消失"——这正是前面 useOptimistic 一节讲的那个坑。第二,addOptimistic 必须在 action 内部、真正发起网络请求的 await 之前调用,这样用户在提交请求打进网络之前就已经看到反馈,乐观更新才名副其实;调用顺序反了,乐观更新就变成了"和真实结果几乎同时出现",起不到应有的效果。这个骨架我在两个真实项目里直接套用过,删删改改就能上。
不是所有表单都要追新
React 19 的新能力很适合改善表单提交流程,但不代表所有旧代码都要立刻迁移。
如果你的项目已经用 React Hook Form、Formik 或内部表单框架,并且校验、错误、提交状态都已经稳定,迁移收益不一定大。尤其是复杂后台表单,字段联动、数组字段、异步校验和草稿恢复往往需要更完整的表单状态模型。
我更建议从小表单开始试,比如登录注册、个人资料保存、评论提交、搜索筛选、简单设置项这类场景,最容易感受到 Actions 的好处,也最不容易引发大规模重构。
React 19 的表单能力不是为了替代所有表单库,而是让常见异步提交流程有了更自然的表达方式。
useActionState 适合收拢提交结果,useFormStatus 适合解耦按钮状态,useOptimistic 适合低风险即时反馈。真正要写好表单,还是得回到那个曾经让一个收银台组件长出十一个互相依赖的 useState 的老问题——提交状态该归谁管、哪些字段要受控、失败了要不要回滚,这些想清楚了,Actions 不过是把答案写得更顺手的地方。