Actions 与 pending 状态
问题:一次提交需要哪些可观察状态
提交不仅是“调用 async 函数”。界面至少要表达:提交的 FormData、是否等待、上一次权威结果和业务错误。React 19 的 useActionState 把 Action 返回值变成 state,并给出可提交的 dispatch Action 与 isPending。
完整实验:保存订购数量
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function saveQuantity(previousState, formData) {
await wait(700)
const quantity = Number(formData.get('quantity'))
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 5) {
return { ...previousState, error: '数量必须是 1 到 5 的整数' }
}
return { saved: quantity, error: '' }
}
function SubmitButton() {
const { pending, data } = useFormStatus()
const sending = data?.get('quantity')
return (
<button type="submit" disabled={pending}>
{pending ? `正在保存 ${sending}…` : '保存'}
</button>
)
}
export default function App() {
const [quantity, setQuantity] = useState('1')
const [state, formAction, isPending] = useActionState(
saveQuantity,
{ saved: null, error: '' },
)
return (
<main>
<h1>订购配额</h1>
<form action={formAction}>
<label>
数量
<input
name="quantity"
inputMode="numeric"
value={quantity}
onChange={(event) => setQuantity(event.target.value)}
/>
</label>
<SubmitButton />
</form>
<p aria-live="polite">{isPending ? '请求处理中' : '请求空闲'}</p>
{state.error && <p role="alert">{state.error}</p>}
{state.saved !== null && !state.error && <p>已保存:{state.saved}</p>}
</main>
)
}useActionState(action, initialState) 返回 [state, dispatchAction, isPending]。当 dispatch 作为 <form action> 时,浏览器构造 FormData,React 在 Action 上下文调用它;action 多收到第一个参数 previousState,原本的 FormData 变成第二个参数。
useFormStatus 必须在目标 form 的后代组件中调用;在创建 form 的同一个组件里调用,看不到该 form 的提交状态。它适合通用提交按钮;useActionState 的 isPending 则对应这一个 Action state。
为什么后一次提交能读取前一次的结果
在已有 Vite 项目中将 App.jsx 暂换成下面的小实验。按钮只负责一次提交两个增量,不读取界面上的旧 count 来计算;控制台记录的 previous 才能告诉我们队列如何前进。
import { startTransition, useActionState } from 'react'
async function accumulate(previous, amount) {
console.log('开始', amount, 'previous=', previous)
await new Promise(resolve => setTimeout(resolve, amount === 1 ? 600 : 100))
const next = previous + amount
console.log('结束', amount, 'next=', next)
return next
}
export default function App() {
const [count, dispatch, pending] = useActionState(accumulate, 0)
return <main>
<p>累计:{count}</p>
<button disabled={pending} onClick={() => {
startTransition(() => { dispatch(1); dispatch(10) })
}}>依次加 1、加 10</button>
{pending && <p role="status">队列处理中</p>}
</main>
}第一次点击日志依次是开始 1/previous=0、结束 1/next=1、开始 10/previous=1、结束 10/next=11。第二项虽然只需等待 100ms,却没有抢在第一项前执行。同一 useActionState 的 dispatch 会排队,后项取得前项返回的 state。这是 Action 队列的保证,不能推广成所有 startTransition 内的异步请求都会自动排序。UI 可以合并提交,别要求每个中间计数都必须单独显示;最终是 11。
返回错误对象时,Action 仍然是正常完成,下一项会收到这份对象。因此 state 结构要一致,错误路径也要保留下一次处理所需的字段。抛出意外异常则会使该队列中的后续 Action 取消,并进入错误边界;它不是“这项失败,下一项自动继续”。需要继续队列的业务失败,应该显式作为结果表达。错误边界可以回看第五章的完整实现,本实验先单独观察正常队列。
在数量表单里提交 2,等待期间把输入改成 5:pending 按钮显示提交快照 2,输入框仍显示正在编辑的 5,最终保存的是 2。FormData 是提交时取得的值;受控输入 state 是当前编辑值,二者不应混为一份状态。
关键变体
- 同一 Action 的提交会按其 Action 队列处理;不要假设每个请求可以任意乱序提交。
- 直接手动调用异步 dispatch 时必须处在 Action/Transition 上下文;把返回的 dispatch 交给 form action 是最直接路径。
- 业务校验失败可返回状态供当前表单显示;意外异常可交给上层错误边界或框架错误处理。
- pending 防止重复点击只是交互保护。服务端仍必须使用幂等键、唯一约束或事务抵御重复请求。
边界
示例 Action 在浏览器内运行,刷新即丢失结果,也没有真正服务端权限。客户端限制 1..5 只改善反馈;服务端若存在,必须重新解析和验证 FormData。Action 不等于 API、数据库事务或身份认证。
练习:契约任务
实现用户名申请表:
- action 接收 previousState 与 FormData;用户名 trim 后长度必须为 3–12。
- 忽略大小写的
taken在 400ms 后返回“用户名已占用”,其他有效值返回“已申请:name”;长度按字符串 length 计,本题不做字素切分。 - 子组件提交按钮用
useFormStatus显示正在提交的用户名并禁用。 - 错误后再次提交有效值必须清除旧错误;输入保持受控,不依赖浏览器自动重置。
完成后对照答案 06-01。
下一篇
pending 让等待可见;下一篇用 useOptimistic 让预期结果先出现。