首页
/ Preact Query 乐观更新(Optimistic Updates)实践指南:通过 UI 变量与缓存回滚两种模式

Preact Query 乐观更新(Optimistic Updates)实践指南:通过 UI 变量与缓存回滚两种模式

2026-09-08 16:59:48作者:咎竹峻Karen

Preact Query(@tanstack/preact-query,仓库内实现位于 packages/preact-query)允许你在一次 mutation 尚未完成前就先行更新界面,从而获得即时反馈的流畅体验,这就是"乐观更新"。本文以 docs/framework/preact/guides/optimistic-updates.md 为骨架展开,完整覆盖两条实现路径:基于 useMutation 返回的 variables 直接驱动 UI(适合单点展示、几乎不需要回滚逻辑),以及基于 onMutate 直接改写查询缓存(适合多组件共享同一份数据、需要对失败做回滚)。读完你将能独立为增删改操作实现既即时又可靠的乐观 UI。

说明:本指南为框架适配文档,其正文由 React 版乐观更新指南 同步生成(仅将 react-query 替换为 preact-queryReact 替换为 Preact),因此下文全部 API 与代码示例都面向 @tanstack/preact-query,导入语句已按 Preact 用法改写。

乐观更新的两种入口

Preact Query 提供了两条在 mutation 完成前乐观更新 UI 的路径:

  1. 经由 UI(useMutation 返回的 variables:直接在渲染层根据 mutation 状态追加/展示临时内容。实现简单,不触碰查询缓存;
  2. 经由缓存(onMutate:在 mutation 发起前直接把新数据写入查询缓存,让所有订阅该 queryKey 的组件自动刷新,并可结合快照在失败时回滚。

选择哪条路取决于"乐观结果要在多少个地方展示",详见文末"何时用哪种方案"。

方案一:经由 UI 渲染乐观结果

这种写法更简单,因为它完全不直接操作缓存。核心思路是利用 useMutation 在发起 mutate 后返回的状态字段(isPendingvariablesisError 等)在组件内渲染出"临时条目"。

基础示例:添加一条 Todo

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

const queryClient = useQueryClient()

const addTodoMutation = useMutation({
  mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
  // 务必 _return_ 失效查询产生的 Promise,
  // 这样 mutation 会一直停留在 `pending` 状态,直到 refetch 完成
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})

const { isPending, submittedAt, variables, mutate, isError } = addTodoMutation

此后你随时可以访问 addTodoMutation.variables,它保存了这次被提交的新 Todo。在渲染查询结果的列表中,当 mutation isPending 时额外追加一项:

<ul>
  {todoQuery.items.map((todo) => (
    <li key={todo.id}>{todo.text}</li>
  ))}
  {isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
</ul>

只要 mutation 处于 pending,我们就渲染一个使用不同 opacity 的临时项。一旦 mutation 成功并触发 onSettled 完成失效重取,这个临时项会自动消失;由于 refetch 成功,该项会以"正常条目"的身份出现在列表中——用户几乎感知不到等待过程。

失败时的表现与重试

如果 mutation 出错,临时项同样会消失。但若你希望在失败后仍保留该条目并允许用户重试,可以利用 mutation 的 isError 状态——variables 在出错时并不会被清空,因此仍然可以读取它,甚至渲染一个重试按钮:

{
  isError && (
    <li style={{ color: 'red' }}>
      {variables}
      <button onClick={() => mutate(variables)}>Retry</button>
    </li>
  )
}

这里 mutate(variables) 会以上一次失败的同一组参数重新触发 mutation,实现原地重试。

当 mutation 与查询不在同一组件时:useMutationState

上述写法在 mutation 与查询同处一个组件时非常顺滑。但如果两者分布在不同的组件里,你可以借助专门的 useMutationState hook 读取任意位置的 mutation 状态。它最适合与 mutationKey 搭配使用:

import { useMutation, useMutationState, useQueryClient } from '@tanstack/preact-query'
import axios from 'axios'

// 应用内的某个位置
const { mutate } = useMutation({
  mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  mutationKey: ['addTodo'],
})

// 在别的组件里访问 variables
const variables = useMutationState<string>({
  filters: { mutationKey: ['addTodo'], status: 'pending' },
  select: (mutation) => mutation.state.variables,
})

需要注意,variables 是一个 数组(Array)——因为同一时刻可能并发运行多个 mutation。若需要为条目生成唯一 key,可以同时用 select 取出 mutation.state.submittedAt。这样即使多个乐观更新并发执行,展示起来也毫无压力:

const pendingTodos = useMutationState({
  filters: { mutationKey: ['addTodo'], status: 'pending' },
  select: (mutation) => ({
    text: mutation.state.variables,
    key: mutation.state.submittedAt,
  }),
})

// 渲染时用 submittedAt 做 key,天然支持并发乐观条目

从实现上看,useMutationState 会订阅 MutationCache:每当缓存发生变化,它就重新调用 getResult 计算匹配结果,并用 replaceEqualDeep 对比新旧值,仅在真正变化时才触发重渲染,最终返回一个数组。其源码见 packages/preact-query/src/useMutationState.ts;顺带一提,useIsMutating 正是 useMutationStatestatus: 'pending' 过滤下的 .length 结果(见同文件 useMutationState.ts),可用于全屏级"保存中"指示。

方案二:经由缓存乐观更新与回滚

当你在执行 mutation 前就乐观地更新了状态,总存在 mutation 失败的可能性。多数失败场景下,直接对乐观更新的查询触发一次 refetch,即可把它们还原到真实的服务端状态。但在某些情况下 refetch 并不能可靠地修复问题——例如 mutation 的报错源于某种服务端异常,导致根本无法重新拉取数据。此时你可以选择**回滚(roll back)**自己的更新。

为此,useMutationonMutate 处理器允许你返回一个值,该值随后会以最后一个参数的形式传入 onErroronSettled 处理器。多数情况下,最有用的做法是返回一份修改前的数据快照(甚至可以返回一个回滚函数)。对应的类型定义可在 packages/query-core/src/types.ts 中查到:

onMutate?: (variables, context: MutationFunctionContext) => Promise<TOnMutateResult> | TOnMutateResult
onError?:  (error, variables, onMutateResult: TOnMutateResult | undefined, context: MutationFunctionContext) => unknown
onSettled?:(data, error, variables, onMutateResult: TOnMutateResult | undefined, context: MutationFunctionContext) => unknown

其中 MutationFunctionContext 形如 { client: QueryClient; meta; mutationKey? }(见 types.ts),这正是下方案例中 context.client.cancelQueries(...) 之类调用之所以可用的原因。

在 query-core 的 mutation.ts 中可以看到这套流程的真实执行顺序:先 dispatch pending 状态(携带 variables),随后调用 mutationCache.config.onMutate 与本次的 options.onMutate,并把 onMutate 的返回值(context)随 pending action 一起存入 mutation 的 state.context;待 mutationFn 结束(成功或失败)后,这个 state.context 会原样传给 onSuccess / onError / onSettled——这就是回滚数据的传递通道。

场景 A:添加新 todo 到列表

以经典 Todo 应用为例,完整模式是"取消进行中的 refetch → 快照旧数据 → 写入乐观值 → 返回快照用于回滚 → 无论成败最后都失效重取":

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

const queryClient = useQueryClient()

useMutation({
  mutationFn: updateTodo,
  // 当 mutate 被调用时:
  onMutate: async (newTodo, context) => {
    // 取消任何正在进行的 refetch
    //(防止它们覆盖我们的乐观更新)
    await context.client.cancelQueries({ queryKey: ['todos'] })

    // 快照旧值
    const previousTodos = context.client.getQueryData(['todos'])

    // 乐观写入新值
    context.client.setQueryData(['todos'], (old) => [...(old ?? []), newTodo])

    // 返回包含快照的结果
    return { previousTodos }
  },
  // 若 mutation 失败,
  // 用 onMutate 返回的结果执行回滚
  onError: (err, newTodo, onMutateResult, context) => {
    context.client.setQueryData(['todos'], onMutateResult?.previousTodos)
  },
  // 无论成功还是失败,最终都执行一次 refetch
  onSettled: (data, error, variables, onMutateResult, context) =>
    context.client.invalidateQueries({ queryKey: ['todos'] }),
})

要点拆解:

  • cancelQueries 先于写入:若有正在进行的查询 fetch 尚未返回,它可能用过期数据覆盖刚写入的乐观值,因此必须先取消;
  • getQueryData 快照:把旧列表存起来作为回滚依据;
  • setQueryData 函数式更新:用回调读取旧值并追加 newTodo(实践中建议像仓库内 useMutation.ts 的示例那样对可能为 undefined 的旧值做 old ?? [] 兜底);
  • 回滚数据的流向return { previousTodos } 会在失败时作为 onError 的第三个参数 onMutateResult 原样送达;
  • onSettled 兜底失效:即使乐观更新失败并回滚,最后仍要 invalidateQueries 与真实服务端对齐。

场景 B:更新单个 todo

当乐观目标不是整个列表而是一条记录时,把 queryKey 收窄到该条目的粒度即可,快照与回滚都围绕它进行:

useMutation({
  mutationFn: updateTodo,
  // 当 mutate 被调用时:
  onMutate: async (newTodo, context) => {
    // 取消任何正在进行的 refetch
    //(防止它们覆盖我们的乐观更新)
    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 }
  },
  // 若 mutation 失败,用上面返回的结果做回滚
  onError: (err, newTodo, onMutateResult, context) => {
    context.client.setQueryData(
      ['todos', onMutateResult?.newTodo.id],
      onMutateResult?.previousTodo,
    )
  },
  // 无论成功还是失败,最终都执行一次 refetch
  onSettled: (data, error, variables, onMutateResult, context) =>
    context.client.invalidateQueries({ queryKey: ['todos', newTodo.id] }),
})

注意 ['todos', newTodo.id] 这类结构化 queryKey 的运用:乐观写入、取消、快照、回滚与最终失效都精确作用于同一条记录,不会波及列表中其他条目的缓存。

用 onSettled 统一成败分支

如果你不希望分开书写 onErroronSuccess,也可以用 onSettled 一把处理——它同时接收 dataerror

useMutation({
  mutationFn: updateTodo,
  // ...
  onSettled: async (data, error, variables, onMutateResult, context) => {
    if (error) {
      // 处理错误(例如按 onMutateResult 回滚)
    }
    // 无论成败都可以在此失效重取
    context.client.invalidateQueries({ queryKey: ['todos'] })
  },
})

需要区分的是:onSettled 中首个参数是 mutationFn 成功解析出的 data(失败时为 undefined),第二个参数才是 error;而回滚快照始终位于第四、第五参数(onMutateResult / context)。按 types.tsonSettled 的完整签名 (data, error, variables, onMutateResult, context) 对号入座即可。

何时用哪种方案

  • 只在唯一一处展示乐观结果:优先选择"经由 UI"的写法——直接消费 variables + isPending 渲染临时条目。它代码量更少、心智负担更低,例如完全不需要处理回滚逻辑。
  • 屏幕上有多个地方需要感知这次更新:直接操作缓存(onMutate + setQueryData)会更省力。因为所有使用同一 queryKey 的查询组件都共享同一份缓存,写入一次即处处生效,不需要逐个组件手动同步状态。

反过来也可以理解为:方案一适合"局部、瞬态、展示用"的乐观内容;方案二适合"数据本身就该变、且被多处消费"的乐观内容。

常见误区与工程建议

  • 务必 return 失效查询的 Promise:在方案一的 onSettled 里,如果写成 () => { queryClient.invalidateQueries(...) } 而不返回,mutation 会立刻结束、临时条目提前消失,可能出现列表闪烁或空窗;显式返回 Promise 才能让 pending 持续到 refetch 收尾(这也是 官方指南 中特别强调的一行注释)。
  • 写入前先取消进行中的 fetch:方案二的成败在于"乐观写入"与"过期响应"的竞争,cancelQueries 是标准解法。深入理解可参考 Query 取消
  • mutationKey 是跨组件定位的基石useMutationState 依赖 filters.mutationKey 精确命中目标 mutation,命名需与 useMutation({ mutationKey }) 保持一致;MutationFilters 支持的所有过滤字段可参考 过滤器(Filters)指南
  • 区分"回滚"与"重取"的适用场景:能可靠 refetch 就 refetch;当错误代表服务端状态损坏、无法再取时,才依赖 onMutate 返回的快照做本地回滚。

延伸阅读

在仓库内可以继续深入以下相关主题:

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390