useOptimistic 是 React 19 新增的 Hook,它让屏幕在 Action 完成之前就显示出 Action 的结果。你在 Action 内部设置一个乐观值;Action 等待期间 React 显示这个值,结束后再回到真实的 state。如果请求失败,真实的 state 从未改变,所以屏幕会自动回滚。
本例中的模拟 API 代替真实请求,每三次调用就失败一次,让你两种结果都能看到。
点击按钮三次,每次之间等一秒。前两次按钮立即切换,下面的“Saved on the server”一行在一秒后跟上。第三次按钮同样立即切换,但请求失败后又翻了回去。把 calls % 3 改成 calls % 2,就会每隔一次点击失败一次。
语法
const [optimisticState, setOptimistic] = useOptimistic(state, updateFn?);
state是真实的值,通常来自useState、props 或useActionState。没有 Action 在等待时,optimisticState就等于这个值。setOptimistic(value)设置乐观值,它在当前 Action 运行期间一直有效。updateFn是可选的:(currentState, optimisticValue) => nextState。有了它,setOptimistic接收的是一个变化,而不是一个全新的值。见下文的 reducer 写法。
一次点击的生命周期总是相同的:
- 在 Action 内部调用
setOptimistic,屏幕立即更新。 - Action 等待请求完成。
- 成功时更新真实的 state(如果是在
await之后更新,要放在startTransition中)。 - Action 结束,
optimisticState重新跟随state:成功时是保存后的值,失败时是原来的值。
第 4 步就是示例中没有回滚代码的原因。catch 块只负责设置错误消息。
必须在 Action 内部运行
乐观值只在 transition 等待期间存在,所以 setOptimistic 必须在 transition 内部调用。以下几种都算:
- 传给
startTransition的函数(或useTransition返回的startTransition), - 传给
<form action={...}>或<button formAction={...}>的函数, - 交给 useActionState 的 Action。
如果在普通的 onClick 中调用,乐观值就没有可以依附的 Action。React 会立即把它撤销,并在开发环境中输出 "An optimistic state update occurred outside a transition or action"。
在 await 之后对真实 state 的更新,应该包在它自己的 startTransition 中,就像上面的 startTransition(() => setLiked(next))。在 await 之后,React 无法判断你是否还处在之前的 transition 中。
用 reducer 写法实现消息列表
当乐观 state 是一个列表时,把更新函数作为第二个参数传入。这样 setter 接收的是新的一项,React 会把它追加到当时真实的列表末尾。
一条一条地发送三条内容不同的消息。每条消息先以半透明状态出现,并带有“(sending...)”,送达后变为正常显示;第三条会从聊天中消失,并显示为“Not sent”。如果快速连续发送,三条消息都会保持半透明,直到最后一个请求结束,因为 React 会一直保留乐观值,直到所有等待中的 Action 都完成。表单 Action 本身就是一个 transition,所以这里的 addOptimistic 不需要再包一层 startTransition。
给每个乐观项一个不会与真实项冲突的 key。示例用的是 pending- 加上消息文本,所以如果同样的文本在两条都等待时发送两次,key 就会冲突;真实的应用应该在客户端生成一个 id。
同时进行多个更新
因为更新函数接收的是当前的 state,所以乐观更新可以叠加。快速点击时,每次等待中的点击都会在上一次的基础上生效。
快速点击四次。购物车立即显示 4。请求完成后,数量最终变为 3,因为第三个请求失败了,控制台也会提示这一点。React 会一直保留乐观值,直到所有等待中的 Action 都完成,然后再显示真实的数量。
什么时候用,什么时候不用
乐观更新适合几乎总能成功、而且容易撤销的操作:点赞、收藏、开关切换、重命名、发送消息。在常见情况下,用户完全看不到加载动画。
如果一个虚假的“已完成”会误导用户,就不要用它:付款、删除账号,以及用户可能在结果出来之前就据此采取行动的任何操作。对于这些情况,用 useActionState 或 useTransition 返回的 isPending 显示等待状态,等待真正的结果。
发生回滚时,一定要告诉用户。一个悄无声息地变回去的值看起来就像 bug。上面两个例子都把错误消息保存在普通的 state 中,这样它在 Action 结束后依然存在。
与 useActionState 一起使用
useOptimistic 和 useActionState 可以很好地配合。useActionState 的 Action 本身就是一个 transition,所以你可以在它开头设置乐观值,而它返回的 state 就是乐观值最终回落到的真实值。
const [state, formAction] = useActionState(async (previous, formData) => {
const title = formData.get('title');
setOptimisticTitle(title);
const saved = await saveTitle(title);
return { title: saved.title };
}, { title: 'Untitled' });
const [optimisticTitle, setOptimisticTitle] = useOptimistic(state.title);
保存期间,页面显示新标题。保存完成后,state.title 存放的是保存后的值。如果 saveTitle 抛出错误,Action 的错误会交给最近的错误边界;如果你想显示旧标题并附上一条消息,就在 Action 内部捕获错误,返回之前的 state 和错误信息。
常见错误
在 Action 外部调用 setter。 乐观值不会留在屏幕上,React 还会在开发环境中发出警告。把它包进 startTransition,或者移到表单 Action 中。
忘记更新真实的 state。 成功时,Action 必须修改 useOptimistic 所跟随的那个 state(setLiked、setMessages)。否则 Action 结束时乐观值会消失,一次成功的请求看起来就像回滚了。
在 await 之后更新真实的 state 却没有用 transition。 更新仍然会生效,但 React 显示它的时机可能与 Action 结束的时机不同。像三个示例那样,把它包进 startTransition。
把乐观值当作数据源。 发送请求、计算总数都要基于真实的 state。乐观值只用于显示,随时可能被丢弃。
常见问题
useOptimistic 有什么用?
它给你一份 state 的副本,在异步 Action 运行期间你可以立即修改它。Action 结束后,这份副本重新跟随真实的 state,而那时真实的 state 要么是保存成功的结果,要么还是原来的值。
useOptimistic 出错时怎么回滚?
它不需要专门的错误处理逻辑。乐观值只在 Action 处于等待状态时存在。如果请求失败,而你从未更新真实的 state,这个 Hook 就会重新显示真实的 state,也就是点击之前的值。
为什么会出现 "An optimistic state update occurred outside a transition or action"?
这是因为 setter 是在普通的事件处理函数中调用的。请在传给 startTransition 的函数中、在 <form action> 中,或在 useActionState 的 Action 中调用它。
useOptimistic 的第二个参数是什么?
一个可选的更新函数 (currentState, optimisticValue) => newState,类似 reducer。有了它,setter 只需接收变化的部分(一条新消息、+1),由 React 计算出乐观 state,即使同时有多个更新在等待也没问题。
useOptimistic 只能用于表单吗?
不是。它在任何 transition 中都能用,所以调用 startTransition(async () => { ... }) 的按钮也可以用它,和表单 Action 一样。