Preact Query 乐观更新(Optimistic Updates)实践指南:通过 UI 变量与缓存回滚两种模式
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-query、React替换为Preact),因此下文全部 API 与代码示例都面向@tanstack/preact-query,导入语句已按 Preact 用法改写。
乐观更新的两种入口
Preact Query 提供了两条在 mutation 完成前乐观更新 UI 的路径:
- 经由 UI(
useMutation返回的variables):直接在渲染层根据 mutation 状态追加/展示临时内容。实现简单,不触碰查询缓存; - 经由缓存(
onMutate):在 mutation 发起前直接把新数据写入查询缓存,让所有订阅该 queryKey 的组件自动刷新,并可结合快照在失败时回滚。
选择哪条路取决于"乐观结果要在多少个地方展示",详见文末"何时用哪种方案"。
方案一:经由 UI 渲染乐观结果
这种写法更简单,因为它完全不直接操作缓存。核心思路是利用 useMutation 在发起 mutate 后返回的状态字段(isPending、variables、isError 等)在组件内渲染出"临时条目"。
基础示例:添加一条 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 正是 useMutationState 在 status: 'pending' 过滤下的 .length 结果(见同文件 useMutationState.ts),可用于全屏级"保存中"指示。
方案二:经由缓存乐观更新与回滚
当你在执行 mutation 前就乐观地更新了状态,总存在 mutation 失败的可能性。多数失败场景下,直接对乐观更新的查询触发一次 refetch,即可把它们还原到真实的服务端状态。但在某些情况下 refetch 并不能可靠地修复问题——例如 mutation 的报错源于某种服务端异常,导致根本无法重新拉取数据。此时你可以选择**回滚(roll back)**自己的更新。
为此,useMutation 的 onMutate 处理器允许你返回一个值,该值随后会以最后一个参数的形式传入 onError 与 onSettled 处理器。多数情况下,最有用的做法是返回一份修改前的数据快照(甚至可以返回一个回滚函数)。对应的类型定义可在 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 统一成败分支
如果你不希望分开书写 onError 与 onSuccess,也可以用 onSettled 一把处理——它同时接收 data 与 error:
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.ts 中 onSettled 的完整签名 (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返回的快照做本地回滚。
延伸阅读
在仓库内可以继续深入以下相关主题:
- Mutation 基础指南:
useMutation的全部选项与生命周期; - 从 mutation 响应更新数据:另一种"利用成功响应写缓存"的更新方式;
- 从 mutation 触发失效:
invalidateQueries在 mutation 成功/失败后的配套用法; - Query 失效:理解失效重取对乐观更新的兜底作用;
- useMutation 的 JSDoc 完整示例:包含基于
useQueryClient()的乐观更新与回滚参考实现(含mutateAsync、Promise.allSettled等进阶写法); - query-core 底层实现:Mutation.execute 流程 与 MutationOptions 类型定义,用于核实回调参数顺序与上下文对象结构。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00