乐观更新
本教程共 38 篇 · 第 12 篇 · 更新于 2026-07-27 · 约 9 分钟阅读
12. 乐观更新
本节目标:理解乐观更新的原理,掌握基于 UI 和基于缓存两种实现方式,学会 onMutate 回滚机制,学完能让用户操作后界面秒变,体验更流畅。
12.1 什么是乐观更新
普通模式下,用户点「点赞」按钮,流程是:发请求 -> 等响应 -> 更新界面。用户得等一会儿才看到点赞数加一。
乐观更新(Optimistic Update)的思路是:用户一点,界面立即变,假设请求会成功。如果真成功了,皆大欢喜;如果失败了,再回滚到之前的状态。
就像你在便利店买东西,扫码付款后店员直接让你拿走商品,假设支付会成功。万一支付失败,再叫你回来。这种「先信任后验证」的体验更快。
Query 提供两种实现乐观更新的方式:基于 UI 和基于缓存。
12.2 方式一:基于 UI 的 variables
这是 v5 推荐的简化方案,不直接动缓存,靠 useMutation 返回的 variables 在 UI 层加临时数据。
const addTodoMutation = useMutation({
mutationFn: (newTodo: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text: newTodo }),
}),
// mutation 完成后失效列表查询
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
const todoQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
然后在渲染列表时,把正在提交的临时项加上去:
<ul>
{todoQuery.data.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
{/* mutation 进行中时,渲染一个临时项 */}
{addTodoMutation.isPending && (
<li style={{ opacity: 0.5 }}>
{addTodoMutation.variables}
</li>
)}
</ul>
addTodoMutation.variables 就是调用 mutate 时传的参数。mutation 进行中时(isPending),把这个临时项渲染出来,半透明表示「还没确认」。mutation 完成后,invalidateQueries 触发列表刷新,临时项被真实数据替换。
失败时怎么办
mutation 失败时,临时项会自动消失(因为 isPending 变 false)。但 variables 不会被清掉,可以用来显示重试按钮:
{addTodoMutation.isError && (
<li style={{ color: 'red' }}>
{addTodoMutation.variables}
<button onClick={() => addTodoMutation.mutate(addTodoMutation.variables)}>
重试
</button>
</li>
)}
Tip这种方式适合「mutation 和查询在同一个组件」的场景。代码少,不用处理回滚。如果乐观更新需要在多处同步显示,用下面讲的缓存方案。
跨组件访问 variables
如果 mutation 和列表不在同一组件,用 useMutationState 配合 mutationKey 跨组件拿 variables:
// 组件 A:发起 mutation
const { mutate } = useMutation({
mutationFn: (newTodo: string) => postTodo(newTodo),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
mutationKey: ['addTodo'],
})
// 组件 B:拿正在进行的 mutation 的 variables
const pendingVariables = useMutationState<string>({
filters: { mutationKey: ['addTodo'], status: 'pending' },
select: (mutation) => mutation.state.variables,
})
// pendingVariables 是数组,因为可能同时有多个
12.3 方式二:基于缓存的 onMutate
这种方案直接改缓存,所有用到这个查询的地方都会立即看到变化。核心靠 onMutate 回调实现回滚。
完整流程:
- onMutate — 取消正在进行的查询,快照旧数据,写入乐观数据,返回快照
- mutationFn — 发请求
- 成功 -> onSettled 失效查询,用真实数据覆盖
- 失败 -> onError 用快照回滚,然后 onSettled 失效
看个往列表加数据的例子:
const queryClient = useQueryClient()
useMutation({
mutationFn: addTodo,
onMutate: async (newTodo, context) => {
// 1. 取消正在进行的查询,防止覆盖我们的乐观更新
await context.client.cancelQueries({ queryKey: ['todos'] })
// 2. 快照旧数据,用于回滚
const previousTodos = context.client.getQueryData(['todos'])
// 3. 写入乐观数据
context.client.setQueryData(['todos'], (old) => [...old, newTodo])
// 4. 返回快照,会传给 onError 和 onSettled
return { previousTodos }
},
onError: (err, newTodo, onMutateResult, context) => {
// 失败了,用快照回滚
context.client.setQueryData(['todos'], onMutateResult.previousTodos)
},
onSettled: () => {
// 不管成功失败,最后都失效查询,用真实数据同步
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
为什么要先 cancelQueries
onMutate 里第一步是取消正在进行的查询。为什么?
假设后台正好有个刷新请求在进行中,如果你不取消它,它返回的数据会覆盖你刚写的乐观数据。取消掉就能保证乐观数据不会被中途的刷新冲掉。
为什么 onSettled 还要 invalidate
乐观更新只是「临时假装成功」。最终还是得用服务端真实数据。onSettled 里失效查询,触发一次真实请求,用服务端的数据校正缓存。
如果 mutation 返回了新数据,也可以用 setQueryData 直接写,省一次请求(后面讲)。
修改单个资源
更新单个 todo 的乐观更新:
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo, context) => {
await context.client.cancelQueries({ queryKey: ['todos', newTodo.id] })
const previousTodo = context.client.getQueryData(['todos', newTodo.id])
// 乐观地写入新值
context.client.setQueryData(['todos', newTodo.id], newTodo)
return { previousTodo, newTodo }
},
onError: (err, newTodo, onMutateResult, context) => {
// 回滚
context.client.setQueryData(
['todos', onMutateResult.newTodo.id],
onMutateResult.previousTodo,
)
},
onSettled: (newTodo, error, variables, onMutateResult, context) =>
context.client.invalidateQueries({ queryKey: ['todos', newTodo.id] }),
})
思路和列表一样,只是 key 更具体,操作的是单个资源。
12.4 用 mutation 响应更新缓存
mutation 成功后,如果返回值就是最新数据,可以跳过失效+重取,直接写缓存:
const mutation = useMutation({
mutationFn: editTodo,
onSuccess: (data) => {
// data 是修改后的完整 todo 对象
queryClient.setQueryData(['todo', { id: data.id }], data)
},
})
mutation.mutate({ id: 5, name: '洗衣服' })
// 下面这个查询会被自动更新成新数据
const { data } = useQuery({
queryKey: ['todo', { id: 5 }],
queryFn: fetchTodoById,
})
这种做法省一次请求,适合「修改单资源,返回值就是完整新资源」的接口。
封装成自定义 hook
把 onSuccess 逻辑封进自定义 hook,复用更方便:
const useMutateTodo = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: editTodo,
onSuccess: (data, variables) => {
queryClient.setQueryData(['todo', { id: variables.id }], data)
},
})
}
12.5 setQueryData 的不可变要求
用 setQueryData 更新缓存时,必须不可变更新。别直接改旧数据:
// 错误:直接改旧对象
queryClient.setQueryData(['posts', { id }], (oldData) => {
if (oldData) {
oldData.title = '新标题' // 别这么干
}
return oldData
})
// 正确:返回新对象
queryClient.setQueryData(['posts', { id }], (oldData) =>
oldData
? { ...oldData, title: '新标题' }
: oldData,
)
直接改旧对象一开始可能没毛病,但会引发结构共享失效、重渲染异常等隐蔽 bug。养成不可变更新的习惯。
Warning这是我踩过的大坑。曾经为了省事直接
oldData.list.push(newItem),结果列表排序时数据错乱,查了两天才发现是可变更新搞坏了缓存引用。
12.6 两种方式怎么选
| 维度 | 基于 UI(variables) | 基于缓存(onMutate) |
|---|---|---|
| 代码量 | 少 | 多 |
| 回滚处理 | 自动(临时项消失) | 手动(onMutate 快照) |
| 多处同步 | 需配合 useMutationState | 自动(改缓存全局生效) |
| 适用场景 | 单处显示 | 多处同步显示 |
简单说:只有一处需要乐观更新,用 variables 方案,省心。多个地方都要看到变化,用 onMutate 改缓存,自动同步。
12.7 乐观更新的风险
乐观更新不是没代价的。假设请求大概率成功时用挺好,但如果失败率高,用户会频繁看到「变了又变回去」,反而比老老实实等 loading 更让人困惑。
判断标准:
- 操作成功率很高(>95%):适合乐观更新
- 操作可能失败(表单校验、库存不足等):别乐观,老老实实 loading
- 涉及钱、权限等敏感操作:千万别乐观,宁可慢也别出错
Tip金融类操作我建议永远不要乐观更新。宁可让用户等 0.5 秒看到确认,也不要冒「显示扣款成功又回滚」的风险。
12.8 小结
乐观更新的两种姿势:
- 基于 UI:用
variables渲染临时项,简单,适合单处 - 基于缓存:
onMutate快照+改缓存,onError回滚,onSettled失效,适合多处同步
核心机制是 onMutate 返回的值会传给 onError 和 onSettled,靠这个实现回滚。mutation 返回新数据时可以用 setQueryData 跳过重取。记得不可变更新,别直接改缓存对象。
下一篇讲分页和无限查询。