React 中的乐观更新:我是在一次评论交互改造里真正把它用明白的

那次要落地的乐观更新,不是点赞按钮这种玩具级场景,而是一个内部内容系统的评论区。

那个页面表面上不复杂:用户输入评论,点击发送,评论出现在列表里。需求也很普通,谁都觉得不难。

最开始的实现很直白。用户点击“发送”后,按钮进入 loading,等接口返回成功,再把新评论插进列表。逻辑一点都不花哨,但体验非常差。公司内网环境本来就不稳定,再加上评论接口还会顺手做审核、敏感词和通知,正常情况下一次请求经常要几百毫秒,偶尔还会上 1 秒。结果就是用户每次点完发送,页面都像卡了一下。

最烦的是,这种迟钝不是“性能慢”的那种大问题,它更像一种持续的小摩擦。用户会不自觉地点第二次,会怀疑刚刚有没有发成功,会盯着按钮看。你从埋点里也能看到,评论提交后的二次点击率很高,但接口明明没有真正失败。

我当时还专门去翻了一下那条评论接口的耗时分布。P50 大概在 400ms,P95 直接飙到 1.2s,最差的几个样本甚至超过 2s。一问才知道,这接口背后串了一长串同步逻辑:先过敏感词,再写库,再触发 @ 通知,再更新评论计数缓存,全在一个请求里同步做完。这种接口你想靠后端优化压到 100ms 以内几乎不现实,短期内也没人愿意去拆。那既然请求本身快不了,体验上的卡顿就只能从前端这一侧想办法消化掉。这其实是我决定上乐观更新的直接原因——不是因为它时髦,而是因为后端那条路当时走不通。

一开始我用的是最常见的临时状态写法

我最早的处理方式其实很传统:

  1. 本地先维护一份 comments
  2. 再维护一份 isSubmitting
  3. 提交时手动把一条临时评论插进去
  4. 请求回来后用真实评论替换掉临时项
  5. 如果失败,再把临时项删掉

这套方案不是不能用,问题在于它很快就会长出一堆边角逻辑。

比如:

  • 临时评论的 id 用什么生成
  • 用户连续发两条时,哪条先回来
  • 如果接口失败,输入框里的文字要不要恢复
  • 列表重新拉取时,临时项和真实项怎么合并

这些东西单看都不大,但它们会让“提交一条评论”这件事越来越像在修一套同步系统,而不是写一个交互。

那次改造到后面,我最大的感受不是“乐观更新可以让页面更快”,而是如果你继续手搓两套状态,迟早会把自己绕进去。

卡住我的是状态到底该由谁来表达,不是快不快

当时最别扭的点在于:我一边想让界面先表现得像成功了,一边又不想把真实状态提前写死。

这其实是两种状态:

  • 用户当前应该看到什么
  • 服务器最终确认了什么

以前我把这两层混在一起处理,所以写到后面只能不断补丁。React 的 useOptimistic 给我的帮助,不是“提供一个新 Hook”,而是把这两层状态硬拆开了。

你可以先明确地告诉 React:提交中的界面长什么样。然后等真实请求回来,再把真实状态落进去。这个分工一旦清楚,代码会安静很多。

我后来是怎么改的

改造之后,我把评论提交拆成两段:

第一段负责立即反馈。用户点发送的那一刻,先用 useOptimistic 往列表里加一条带 pending 标记的评论,这样界面立刻变化,输入框也能马上清空。

第二段负责异步收口。接口成功后,把服务端返回的真实评论写回去;失败的话,就把这条临时评论回滚掉,并把输入内容恢复出来。

核心写法和官方文档思路差不多,但我在业务里更在意的是三件事:

1const [optimisticComments, addOptimisticComment] = useOptimistic(
2  comments,
3  (currentComments, newComment) => [
4    ...currentComments,
5    { ...newComment, pending: true }
6  ]
7);
8
9function handleSubmit(text) {
10  const tempComment = {
11    id: crypto.randomUUID(),
12    text
13  };
14
15  startTransition(async () => {
16    addOptimisticComment(tempComment);
17    await saveComment(tempComment);
18  });
19}

第一,临时评论必须能被稳定识别。否则后面失败回滚、成功替换都会很难做。

第二,失败路径不能是“以后再说”。我一开始就是在这件事上吃亏,导致偶发失败时列表里会留下幽灵评论。

第三,乐观更新最好局部做,不要一上来改整个页面的共享状态。那次我只把评论列表这一段先改了,没有顺手去动评论数、消息中心、右侧推荐这些联动区域。事实证明这是对的,因为局部先跑通以后,边界会清楚很多。

后来我又补了一个规则:乐观状态里要显式标出来源。

1const tempComment = {
2  id: crypto.randomUUID(),
3  clientId: crypto.randomUUID(),
4  text,
5  status: "pending",
6  source: "optimistic"
7};

idclientIdstatus 这些字段看起来啰嗦,但它们能解决很多后续问题。服务端返回真实评论后,你能知道该替换哪一条;请求失败后,你能知道哪一条可以回滚;列表重新拉取后,你能判断临时项是不是已经被真实项覆盖。

startTransition 在这里帮我的,不是“异步”,而是优先级

很多人第一次看到 startTransition,会下意识把它理解成异步包装器。实际开发里我更把它当成“让 React 知道这次更新不必抢最高优先级”。

像评论提交这种交互,用户最在意的是:

  • 按钮点下去立刻有反馈
  • 输入框别卡
  • 列表看起来已经变了

至于后面那次真实状态写回,并不是最强实时的那种更新。startTransition 的好处就在这里,它不会把所有状态更新一股脑地压到最前面。页面整体的手感会更平顺,不是那种“逻辑都对,但总觉得哪里发沉”的状态。

和 Server Action、useActionState 放在一起看

useOptimistic 单独用已经能解决体感问题,但我很快发现它真正顺手是和 React 19 的表单 Action 配套。以前提交要自己维护 isSubmitting,现在可以把提交逻辑写成一个 action,交给 useActionState 管,useOptimistic 负责提交那一瞬间的界面。两者分工很清楚:一个管“最终这次提交成没成、拿到什么”,一个管“提交中用户先看到什么”。

1const [state, submitAction, isPending] = useActionState(
2  async (prevState, formData) => {
3    const text = formData.get("text");
4    const saved = await saveComment({ text });
5    return { comments: [...prevState.comments, saved] };
6  },
7  { comments: initialComments }
8);
9
10const [optimisticComments, addOptimistic] = useOptimistic(
11  state.comments,
12  (current, pending) => [...current, { ...pending, pending: true }]
13);

表单直接 <form action={submitAction}> 绑上去,isPending 拿来禁用按钮,optimisticComments 拿来渲染列表。这一套下来,我手里那份 isSubmitting 状态就彻底删掉了——它本来就是在替 React 记一件 React 现在自己能记的事。

有一个细节要提醒:乐观状态只在 transition 或 action 进行期间有效,action 一结束,React 会自动丢弃乐观值、回到真实 state。所以我不需要自己写“成功后把 pending 项清掉”的逻辑,这一步框架帮我做了。早期我还画蛇添足地手动清过一次,结果和框架的自动回收打架,列表闪了一下,删掉那段反而对了。

不是所有交互都该乐观更新

那次改造之后,我有一段时间很上头,觉得很多操作都可以乐观一点。后来很快就收住了,因为有些地方用起来确实不合适。

我现在基本用这几个标准判断:

  • 成功率高不高
  • 失败后能不能自然回滚
  • 即时反馈值不值那点复杂度

点赞、收藏、关注、评论追加,这些通常适合。

支付、库存扣减、权限切换、关键配置保存,这些我就会谨慎很多。因为一旦失败,用户看到的不是“界面闪了一下”,而是对系统信任直接下降。

还有一种情况也要谨慎:成功率虽然高,但失败后解释成本很高。

比如审批状态、发布状态、权限开关,这些操作从接口角度看可能很稳定,但用户一旦看到“已经成功”,后面又回滚,就会非常困惑。乐观更新不是只看技术成功率,还要看业务语义能不能接受回滚。

我现在会把乐观更新分成几类:

1适合:点赞、收藏、评论追加、轻量偏好设置
2谨慎:发布、审批、权限、库存、金额
3不适合:支付、删除关键数据、不可逆操作

越不可逆,越不应该先让用户看到“成功”。

失败时不要只静默回滚

乐观更新失败时,最偷懒的做法是把 UI 变回去。

但用户刚才明明做了动作,如果界面突然恢复原样,他会不知道发生了什么。评论提交失败时,我更倾向于把临时评论保留成失败态,并给重试入口。

1{
2  id: "temp-1",
3  text: "这是一条评论",
4  status: "failed",
5  errorMessage: "发送失败,点击重试"
6}

这种体验比直接消失更好。用户知道系统没有吞掉他的输入,也知道下一步能做什么。

对于评论、消息、表单草稿这类“用户输入成本比较高”的内容,失败后最好保留文本。不要让用户因为一次网络错误重新打一遍。

连续提交和乱序回来是绕不开的坑

评论区还有一个我一开始没算到的情况:用户手快,连着发两条,第一条接口慢、第二条接口快,结果第二条先回来。如果替换逻辑只认“最新一次请求”,就可能把先发的那条覆盖掉,或者两条临时项和两条真实项对不上号。

解决办法其实就落在前面那个 clientId 上。每条乐观评论生成时带一个客户端唯一 id,接口成功后服务端把这个 id 原样带回来,替换时按 clientId 匹配,而不是按数组位置或提交顺序。

1function reconcile(list, saved) {
2  // 按 clientId 精确替换,不依赖回来的先后顺序
3  return list.map((item) =>
4    item.clientId === saved.clientId
5      ? { ...saved, pending: false }
6      : item
7  );
8}

有了这一步,谁先回来都无所谓,每条真实评论都能找到自己那条临时项。乱序不再是问题,因为我们根本没依赖顺序。这也是我后来坚持“临时项必须带稳定标识”的原因——它不是为了好看,是为了让并发场景有一个确定的对齐依据。

顺带说一句,把这些提交都包在 startTransition 或 action 里还有个好处:多次乐观更新之间 React 会自己排队协调,不会因为两次 setState 撞在一起丢状态。这层调度以前得自己小心翼翼地处理,现在算是交出去了。

和服务端状态同步要有收口

乐观更新只是临时状态,最终还是要回到服务端事实。

我后来会在成功后做两件事:

  • 用服务端返回的真实数据替换临时项。
  • 在合适时机重新拉取一次列表或局部数据,确保和服务端一致。

这不是不信任前端,而是防止遗漏服务端补充字段,比如审核状态、创建时间、用户头像、权限标记。乐观更新只负责“先给反馈”,不能替代最终一致性。

还有一个容易被忽略的收口点:乐观更新解决了“看起来卡”,但没解决“用户重复点”。我在开头提到过那个很高的二次点击率,乐观更新让评论立刻出现之后,这个数字确实降了不少,但没有归零——因为总有人手快,在临时项渲染出来之前又点了一下。所以按钮该禁用还是要禁用,两条防线一起上:

1<button
2  type="submit"
3  disabled={isPending}
4  onClick={handleSubmit}
5>
6  {isPending ? "发送中" : "发送"}
7</button>

isPending 一方面给按钮加了状态提示,一方面在提交进行期间挡住重复提交。乐观更新负责让界面看起来已经响应了,禁用态负责堵住那零点几秒里的重复动作,两者解决的不是同一个问题,缺哪个都会留一道缝。

要不要直接上数据层的乐观能力

有人会问,既然 TanStack Query 这类库早就有 onMutate 加回滚的乐观更新,为什么还要用 useOptimistic。这两件事其实不冲突,我判断的标准是这个乐观状态归谁管。

如果乐观的对象本来就是一份被缓存、会被别处复用、还要跟服务端做失效同步的数据(比如列表分页缓存),那放在数据层更合适,回滚和重新拉取都是现成的。但评论输入框这种,乐观状态是很局部的 UI 事实,只服务于当前这个交互,用不着惊动全局缓存。这种时候我更愿意用 useOptimistic 就地解决,避免为了一个按钮的即时反馈去动整份查询缓存的失效逻辑。

这与其说是“数据层乐观还是组件级乐观”的选择题,不如说是一条连续的判断:这个乐观值要不要被别处复用、要不要跨组件保持一致、提交结束后是不是还得留着给别人读。越贴近这几条,就越该交给数据层的失效同步机制去管;只是这一下的即时反馈,交给组件自己管就够了。分不清这条线,容易两头都用一点,最后同一份数据有两套乐观逻辑在打架。我的经验是先问“这个乐观值提交结束后还需要留着吗”,需要留、要被别处读,就交给数据层;只是这一下的即时反馈,就用组件级的。

等待太久的表象下面,是状态建模的问题

前端很多交互问题表面上像是“等待太久”,其实根子在状态建模。

如果你的代码里只有“提交前”和“提交后”两种状态,那你只能让用户等。可真实交互里明明还有一种状态很重要,就是“用户已经做了动作,系统也应该先给反应,但后端还没最终确认”。

useOptimistic 的价值就在这里。它不是为了让页面看起来花哨一点,而是让这层状态终于有了正式写法。

我现在看乐观更新,已经不太把它当作“体验优化技巧”了,更像是一种交互建模方式。

当你明确区分“用户现在应该看到什么”和“服务器最终确认了什么”之后,很多原来看起来只能靠补丁处理的逻辑,都会顺下来。React 这两个 API 好用的地方,不是新,而是它们终于把这件事讲清楚了。