Notes

TanStack Query:乐观更新与一致性(Optimistic Updates & Consistency)

一句话结论:乐观更新不是“让缓存提前变真”,而是先用本地推演结果缩短交互反馈,再用 回滚(rollback)+ 失效刷新(invalidate/refetch)+ 冲突处理(reconcile) 保证最终以服务端为准的一致性;工程关键在于:更新范围(哪些 queryKey)、竞态控制(cancel/乱序返回)、以及把最终一致性做成可预期的规则

1. 先对齐术语:你到底在一致什么?

在真实业务里你通常不是追求“强一致”,而是在用户体验与正确性之间做权衡。

  • source of truth(真相):服务端
  • query cache(副本):客户端缓存(可被 invalidate)
  • optimistic state(乐观态):为了体验,提前把 UI 变成“看起来成功”的状态
  • final state(最终态):服务端确认后的状态(可能与乐观态不同)

经验:乐观更新的本质是“先让 UI 好用”,而不是“绕过服务端”。

2. TanStack Query 的标准乐观更新生命周期(useMutation)

一套可复用的心智模型:

  • onMutate:写入乐观结果前的准备阶段
    • 取消相关查询(避免旧响应覆盖)
    • 读取快照(用于回滚)
    • 写入乐观结果(setQueryData)
  • onError:失败回滚
  • onSuccess:用服务端回包 reconcile(必要时覆盖乐观结果)
  • onSettled:兜底 invalidate,做最终一致性校准

2.1 最小代码骨架(示意)

import { useMutation, useQueryClient } from '@tanstack/react-query'

function useToggleTodoDone() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ id, done }: { id: string; done: boolean }) => {
      return api.todo.update(id, { done })
    },

    onMutate: async ({ id, done }) => {
      // 1) 避免竞态:取消可能覆盖本次乐观写入的 in-flight 请求
      await queryClient.cancelQueries({ queryKey: ['todo', id] })

      // 2) 快照:用于回滚
      const prev = queryClient.getQueryData(['todo', id])

      // 3) 写入乐观结果(detail)
      queryClient.setQueryData(['todo', id], (old: any) => ({
        ...old,
        done,
        __optimistic: true,
      }))

      return { prev }
    },

    onError: (_err, { id }, ctx) => {
      // 4) 失败回滚
      if (ctx?.prev) queryClient.setQueryData(['todo', id], ctx.prev)
    },

    onSuccess: (serverTodo, { id }) => {
      // 5) 用服务端回包做 reconcile(以服务端为准)
      queryClient.setQueryData(['todo', id], serverTodo)
    },

    onSettled: (_data, _err, { id }) => {
      // 6) 兜底最终一致性(特别是你没在 onSuccess 里覆盖时)
      queryClient.invalidateQueries({ queryKey: ['todo', id] })
    },
  })
}

注意:上面只演示 detail。真实项目还要考虑 list、分页、聚合统计等“展示面”。

3. 你到底要更新哪些 cache?(更新范围设计)

乐观更新最容易做错的不是 API,而是“范围”。

3.1 三种典型范围

  1. 只更新详情(detail only)
  • 适用:页面只展示详情,或列表不需要立即同步
  • queryKey:['todo', id]
  1. 同步更新列表 + 详情(list + detail)
  • 适用:列表里有该项且用户会立刻看到变化(例如 toggle done / like)
  • queryKey:
    • detail:['todo', id]
    • list:['todos', { filters... }](注意:可能有多个不同 filters 的列表)
  1. 分页/无限列表(paginated/infinite)
  • 适用:长列表
  • 约束:不要 O(n) 全表重建;尽量做“局部 patch”

3.2 经验法则

  • 只同步“业务关键视图”的列表(命中率高的那几种 filters),不要把 invalidate/patch 扩散到全站
  • 同一实体可能出现在多个列表:要么明确同步哪些列表,要么索性不做 list patch 只 invalidate(用正确性换一点体验)

4. 常见坑(生产中最常见的事故来源)

  1. 没 cancelQueries 导致竞态
  • 场景:你乐观写入后,旧的 refetch 响应回来了,把缓存覆盖回旧值
  • 解决:onMutate 里 cancel 相关 queries
  1. queryKey 设计不一致
  • 场景:你 patch 了 ['todos'],但页面用的是 ['todos', { page, keyword }]
  • 解决:统一 queryKey 工厂函数(建议集中在一个模块)
  1. 只更新列表不更新详情(或反过来)
  • 结果:同一实体在不同页面出现“多源真相”
  1. 创建(POST)没有临时 ID
  • 结果:列表里无法稳定替换、回滚困难、UI 抖动
  1. 并发 mutation(连点)+ 乱序返回
  • 结果:后返回的响应覆盖前返回,状态来回跳
  • 解法:
    • UI 上禁用按钮/队列化
    • 或以 updatedAt/version 做 reconcile(只接受更“新”的回包)

5. 常用落地模式(你可以按业务选择)

Pattern A:Invalidate 驱动(正确性优先,默认推荐)

  • mutation 成功 → invalidateQueries → 让页面自动刷新
  • 优点:简单、正确性高
  • 缺点:交互反馈慢(依赖网络)

Pattern B:标准乐观更新(体验与正确性平衡)

  • onMutate:snapshot + setQueryData(局部 patch)
  • onError:rollback
  • onSuccess:用服务端回包 reconcile
  • onSettled:invalidate 做最终一致性兜底

适用:

  • toggle、点赞、已读/未读、轻量字段编辑

Pattern C:Create(POST)+ 临时 ID 替换

  • onMutate
    • 生成 tempId
    • 先把临时 item 插入 list(可标记 __optimistic: true
  • onSuccess:把 tempId 替换为服务端 id,并补全字段
  • onError:移除临时项 / 回滚列表

Pattern D:Delete(DELETE)+ Undo(可选)

  • onMutate:从列表移除,并缓存被删项用于撤销
  • onError:插回
  • onSettled:invalidate 校准

6. Checklist

  • 这次 mutation 影响哪些 queryKey?(list / detail / 聚合统计)
  • onMutate 是否 cancelQueries 避免竞态覆盖?
  • 是否保存了可回滚的 snapshot(context)?
  • patch 是否不可变更新(不要直接 mutate 原对象)?
  • onError 回滚是否能恢复到“用户可理解”的状态?
  • onSuccess 是否用服务端回包 reconcile(权限/校验/补全字段)?
  • onSettled 是否有 invalidate 作为最终一致性兜底?粒度是否可控?
  • 是否处理并发 mutation(连点/乱序返回)?
  • 是否避免“多源真相”(store/组件 state/URL/query cache 重复存一份)?

关联阅读

cd ..