首页 / TanStack 生态入门教程 / 乐观更新

TanStack 生态入门教程

乐观更新

本教程共 38 篇 · 第 12 篇 · 更新于 2026-07-27 · 约 9 分钟阅读

TanStackTanStack 生态入门教程乐观更新optimistic updatesonMutate回滚setQueryDatavariables

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 回调实现回滚。

完整流程:

  1. onMutate — 取消正在进行的查询,快照旧数据,写入乐观数据,返回快照
  2. mutationFn — 发请求
  3. 成功 -> onSettled 失效查询,用真实数据覆盖
  4. 失败 -> 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 返回的值会传给 onErroronSettled,靠这个实现回滚。mutation 返回新数据时可以用 setQueryData 跳过重取。记得不可变更新,别直接改缓存对象。

下一篇讲分页和无限查询。