登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

React useOptimistic 如何处理失败回滚与并发更新

来源:17golang原创

时间:2026-10-10 01:37:24 286浏览 收藏

热门推荐
漫画APP
动画内容聚合,热门资源快捷查看
立即下载

我第一次把 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:告诉用户哪次操作失败,不承担列表回滚。
confirmedItems、纯 reducer、optimisticItems、Action 与错误状态的静态职责关系图
图1:结构图。confirmedItems 是服务端确认后的权威状态,纯 reducer 只负责叠加临时变化,错误信息由独立状态展示;本图不是运行截图。

如果 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 是并发合并的锚点,数组下标不能替代它,因为列表插入、排序和删除都会改变下标。

最新权威列表、两个 pending action、稳定 ID 与纯 reducer 的静态关系图
图2:结构图。每个 pending action 通过稳定 ID 描述自己的变更,纯 reducer 在最新 confirmedItems 上重算 optimisticItems,避免旧闭包覆盖并发到达的新数据。

提交成功结果时也要使用函数式更新,例如 setConfirmedItems(current => ...)。这样更新基于提交时的当前状态,而不是事件处理函数创建时捕获的数组。若服务端返回整份最新列表,可以直接以响应作为新的权威值;若只返回单条结果,则按稳定 ID 合并,不要假设响应顺序等于操作顺序。

对于“同一项目允许多次快速改数量”的场景,还要决定产品语义:是禁用同一项目的重复操作、合并增量,还是让服务端按版本号或请求序列解决冲突。useOptimistic 负责临时 UI,不会替你定义服务端并发协议。涉及金额、库存、权限或不可逆操作时,我更倾向于限制重复提交,并以服务端响应为最终结果。

按六层证据排查异常

  1. Action 边界:乐观 setter 是否在 startTransition 或 Action prop 中调用?如果在外部调用,React 会给出警告,临时状态也可能只短暂出现。
  2. 权威状态:失败分支是否仍调用了 setConfirmedItems?只要失败时提交了错误数据,自动回滚就无从发生。
  3. reducer 纯度:是否原地修改数组、对象,或读取了外部可变变量?reducer 应只依赖参数并返回新值。
  4. 闭包来源:成功合并是否使用函数式更新?若直接使用事件创建时的 confirmedItems,并发结果容易互相覆盖。
  5. 稳定标识:action 是否携带稳定的业务 ID?用数组下标会让删除、排序和插入后的目标发生漂移。
  6. 服务端语义:接口是否幂等、是否返回确认值、同一实体冲突如何处理?UI 乐观不等于服务端天然支持并发。

反向验证时,我会故意让一个请求失败,同时让另一个请求成功并返回新数据。正确结果应该是:失败项目恢复并显示错误,成功项目保留服务端确认结果,等待期间到达的新项目不丢失。这个测试比只看一次成功动画更能暴露旧快照问题。

常见问题

useOptimistic 失败后需要手工 dispatch rollback 吗?

通常不需要。只要失败时没有更新传给 useOptimistic 的权威值,Action 结束后就会显示当前权威状态。需要手工维护的是错误提示、重试入口或业务补偿,而不是把旧数组整体写回。

为什么我在 catch 中恢复快照仍会丢数据?

因为快照来自请求开始时,等待期间可能已经出现新的 props、其他 Action 或服务端推送。恢复整份快照会覆盖这些变化。应该让权威状态保留最新值,乐观 reducer 只描述本次操作。

并发添加时临时 ID 怎么处理?

客户端先生成唯一临时 ID,并在服务端成功后用响应中的正式 ID 替换或以服务端列表为准。不要用数组下标,也不要让多个 pending 项共享同一个临时 ID。

所有请求都适合乐观更新吗?

不适合。成功概率低、冲突代价高、结果不可逆,或者必须先由服务端校验的操作,更适合显示明确 pending 状态后再呈现结果。乐观更新的价值是降低可预测操作的等待感,不是隐藏失败和并发规则。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>