React useOptimistic 如何处理失败回滚与并发更新
来源:17golang原创
时间:2026-10-10 01:37:24 286浏览 收藏
我第一次把 useOptimistic 用到列表删除时,最容易写错的地方不是“先隐藏一行”,而是失败后又手工把旧数组塞回去。这样单次操作看似能用,两个请求重叠后却可能把后到的新数据一起覆盖。更稳妥的做法是:把服务端确认的数据作为唯一权威状态,让 useOptimistic 只在 Action 进行期间叠加临时变化;请求失败时不提交权威状态,Action 结束后界面自然回到当前权威值,错误信息则由独立状态显示。
官方文档:https://react.dev/reference/react/useOptimistic
useOptimistic 不是第二份永久数据。失败回滚的关键是“失败时不要修改权威状态”;并发更新的关键是“用纯 reducer 基于最新权威值重算”,而不是捕获一次旧数组后反复覆盖。
两个常见症状其实来自同一个边界错误
我通常先看两个现象。第一,请求失败后,被删除的项目没有重新出现,或者虽然出现了,却把其他刚加入的项目弄丢了。第二,快速连续点击两个项目时,只有最后一次操作留下,另一条状态像被覆盖。它们往往都说明组件把“服务端确认的数据”和“临时展示的数据”混成了一份。
| 现象 | 更可能的原因 | 应该观察的证据 |
|---|---|---|
| 失败后仍保持临时结果 | 失败分支也更新了权威状态 | 检查 catch/finally 是否调用了 setItems |
| 回滚后丢了新数据 | 把请求前的旧数组手工写回 | 检查是否保存 snapshot 并整体覆盖 |
| 连续操作互相覆盖 | reducer 不纯或使用旧闭包 | 检查变更是否基于 reducer 的 current 参数 |
| 乐观状态一闪而过 | setter 不在 Action/Transition 中 | 查看 React 警告与 startTransition 边界 |
我不再为每个失败请求保存整份数组快照。快照只知道请求开始那一刻的数据,不知道等待期间父组件、其他用户或另一个 Action 带来了什么更新。把旧快照整体写回,才是并发场景里最危险的“回滚”。
先分清权威状态、乐观层和错误反馈
React 官方定义很清楚:没有 pending Action 时,useOptimistic 返回传入的 value;Action 进行时,才返回 reducer 计算出的临时状态。Action 结束后,最终展示什么由最新的 value 决定。这个边界意味着三个职责不要混在一起:
confirmedItems:已经被服务端确认的权威列表。optimisticItems:在 pending Action 上叠加临时变更后的展示列表。error state:告诉用户哪次操作失败,不承担列表回滚。

如果 Action 抛错,而父级没有更新 confirmedItems,Transition 结束后 React 会重新显示当前权威值,这就是自动回滚。我们仍然应该捕获错误,用于提示、日志或重试入口,但不要在 catch 中把请求前的旧数组重新写回。
用纯 reducer 描述变更,不要保存旧数组
下面先把删除操作写成一个纯 reducer。它只根据 currentItems 和 action 返回新数组,不修改参数,也不读取外部可变变量。这里暂时不把项目直接移除,而是标记 deleting,让用户能看见当前项目正在等待确认。
// 纯函数:只根据当前列表和 action 计算下一份乐观列表
function optimisticReducer(currentItems, action) {
switch (action.type) {
case 'delete':
return currentItems.map((item) =>
item.id === action.id
? { ...item, deleting: true }
: item
);
default:
// 未知 action 不改变当前列表,便于后续扩展
return currentItems;
}
}
这里最重要的不是 switch,而是 currentItems。当权威列表在 Action 等待期间发生变化时,React 可以用新的 confirmedItems 重新运行 reducer,把尚未完成的乐观变更叠加到最新数据上。相比之下,从事件处理函数闭包中读取旧 items,很容易漏掉等待期间到达的数据。
失败回滚只需要守住权威状态
完整组件可以把删除请求放进 startTransition。乐观 setter 必须在 Action 内调用;请求成功后再更新权威状态,请求失败只记录错误。异步等待后的权威状态提交再包一层 startTransition,可以明确把该更新留在 Transition 边界内。
import { startTransition, useOptimistic, useState } from 'react';
export default function ItemList({ initialItems, deleteItem }) {
// confirmedItems 只保存服务端已经确认的数据
const [confirmedItems, setConfirmedItems] = useState(initialItems);
const [errors, setErrors] = useState({});
const [optimisticItems, dispatchOptimistic] = useOptimistic(
confirmedItems,
optimisticReducer
);
function handleDelete(id) {
// 清掉本项目的旧错误,但不改列表权威状态
setErrors((current) => ({ ...current, [id]: null }));
startTransition(async () => {
// 立即标记这一项,setter 位于 Action 内
dispatchOptimistic({ type: 'delete', id });
try {
await deleteItem(id);
startTransition(() => {
// 只有服务端成功后,才提交权威列表
setConfirmedItems((current) =>
current.filter((item) => item.id !== id)
);
});
} catch (error) {
// 不写回旧快照;Action 结束后会回到当前 confirmedItems
setErrors((current) => ({
...current,
[id]: error instanceof Error ? error.message : '删除失败'
}));
}
});
}
return (
-
{optimisticItems.map((item) => (
-
{item.name}
{errors[item.id] && (
{errors[item.id]},请重试。
)}
))}
这段写法有一个我很看重的取舍:失败时项目会恢复,但错误提示还留在对应项目旁边。用户能理解“刚才没删掉”,也能重试;组件不需要维护一套额外的 undo 快照。对于会直接从列表消失的乐观删除,也可以让 reducer 过滤项目,但错误提示要放在列表外或 Toast 中,否则项目恢复前没有位置显示。
并发更新要基于最新权威列表重算
连续删除 A、B 时,两次 Action 可能同时 pending。安全的模型不是“请求 A 保存快照 1、请求 B 保存快照 2”,而是每个 Action 只携带自己的 item.id,由纯 reducer 把这些变更叠加到最新权威列表上。稳定 ID 是并发合并的锚点,数组下标不能替代它,因为列表插入、排序和删除都会改变下标。

提交成功结果时也要使用函数式更新,例如 setConfirmedItems(current => ...)。这样更新基于提交时的当前状态,而不是事件处理函数创建时捕获的数组。若服务端返回整份最新列表,可以直接以响应作为新的权威值;若只返回单条结果,则按稳定 ID 合并,不要假设响应顺序等于操作顺序。
对于“同一项目允许多次快速改数量”的场景,还要决定产品语义:是禁用同一项目的重复操作、合并增量,还是让服务端按版本号或请求序列解决冲突。useOptimistic 负责临时 UI,不会替你定义服务端并发协议。涉及金额、库存、权限或不可逆操作时,我更倾向于限制重复提交,并以服务端响应为最终结果。
按六层证据排查异常
- Action 边界:乐观 setter 是否在
startTransition或 Action prop 中调用?如果在外部调用,React 会给出警告,临时状态也可能只短暂出现。 - 权威状态:失败分支是否仍调用了
setConfirmedItems?只要失败时提交了错误数据,自动回滚就无从发生。 - reducer 纯度:是否原地修改数组、对象,或读取了外部可变变量?reducer 应只依赖参数并返回新值。
- 闭包来源:成功合并是否使用函数式更新?若直接使用事件创建时的
confirmedItems,并发结果容易互相覆盖。 - 稳定标识:action 是否携带稳定的业务 ID?用数组下标会让删除、排序和插入后的目标发生漂移。
- 服务端语义:接口是否幂等、是否返回确认值、同一实体冲突如何处理?UI 乐观不等于服务端天然支持并发。
反向验证时,我会故意让一个请求失败,同时让另一个请求成功并返回新数据。正确结果应该是:失败项目恢复并显示错误,成功项目保留服务端确认结果,等待期间到达的新项目不丢失。这个测试比只看一次成功动画更能暴露旧快照问题。
常见问题
useOptimistic 失败后需要手工 dispatch rollback 吗?
通常不需要。只要失败时没有更新传给 useOptimistic 的权威值,Action 结束后就会显示当前权威状态。需要手工维护的是错误提示、重试入口或业务补偿,而不是把旧数组整体写回。
为什么我在 catch 中恢复快照仍会丢数据?
因为快照来自请求开始时,等待期间可能已经出现新的 props、其他 Action 或服务端推送。恢复整份快照会覆盖这些变化。应该让权威状态保留最新值,乐观 reducer 只描述本次操作。
并发添加时临时 ID 怎么处理?
客户端先生成唯一临时 ID,并在服务端成功后用响应中的正式 ID 替换或以服务端列表为准。不要用数组下标,也不要让多个 pending 项共享同一个临时 ID。
所有请求都适合乐观更新吗?
不适合。成功概率低、冲突代价高、结果不可逆,或者必须先由服务端校验的操作,更适合显示明确 pending 状态后再呈现结果。乐观更新的价值是降低可预测操作的等待感,不是隐藏失败和并发规则。
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
418 收藏
-
239 收藏
-
文章 · 前端 | 9小时前 | javascript · AbortController AbortSignal.any AbortError TimeoutError AbortSignal reason447 收藏
-
110 收藏
-
120 收藏
-
202 收藏
-
257 收藏
-
315 收藏
-
356 收藏
-
158 收藏
-
234 收藏
-
446 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习