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 三种典型范围
- 只更新详情(detail only)
- 适用:页面只展示详情,或列表不需要立即同步
- queryKey:
['todo', id]
- 同步更新列表 + 详情(list + detail)
- 适用:列表里有该项且用户会立刻看到变化(例如 toggle done / like)
- queryKey:
- detail:
['todo', id] - list:
['todos', { filters... }](注意:可能有多个不同 filters 的列表)
- detail:
- 分页/无限列表(paginated/infinite)
- 适用:长列表
- 约束:不要 O(n) 全表重建;尽量做“局部 patch”
3.2 经验法则
- 只同步“业务关键视图”的列表(命中率高的那几种 filters),不要把 invalidate/patch 扩散到全站
- 同一实体可能出现在多个列表:要么明确同步哪些列表,要么索性不做 list patch 只 invalidate(用正确性换一点体验)
4. 常见坑(生产中最常见的事故来源)
- 没 cancelQueries 导致竞态
- 场景:你乐观写入后,旧的 refetch 响应回来了,把缓存覆盖回旧值
- 解决:
onMutate里 cancel 相关 queries
- queryKey 设计不一致
- 场景:你 patch 了
['todos'],但页面用的是['todos', { page, keyword }] - 解决:统一 queryKey 工厂函数(建议集中在一个模块)
- 只更新列表不更新详情(或反过来)
- 结果:同一实体在不同页面出现“多源真相”
- 创建(POST)没有临时 ID
- 结果:列表里无法稳定替换、回滚困难、UI 抖动
- 并发 mutation(连点)+ 乱序返回
- 结果:后返回的响应覆盖前返回,状态来回跳
- 解法:
- UI 上禁用按钮/队列化
- 或以
updatedAt/version做 reconcile(只接受更“新”的回包)
5. 常用落地模式(你可以按业务选择)
Pattern A:Invalidate 驱动(正确性优先,默认推荐)
- mutation 成功 →
invalidateQueries→ 让页面自动刷新 - 优点:简单、正确性高
- 缺点:交互反馈慢(依赖网络)
Pattern B:标准乐观更新(体验与正确性平衡)
onMutate:snapshot + setQueryData(局部 patch)onError:rollbackonSuccess:用服务端回包 reconcileonSettled: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 重复存一份)?
关联阅读
- 状态分层(source of truth):
./01-state-layering-ui-server-url-global.md - Query 缓存与失效(invalidate 基础):
./02-tanstack-query-caching-and-invalidation.md - Transition(输入与结果区解耦,降低“必须乐观”的压力):
../09-react-18-in-practice/03-transition-useTransition-useDeferredValue.md - Suspense 与加载边界:
../09-react-18-in-practice/04-suspense-loading-boundary-design.md