Preact Query 实战:Mutation 成功后如何自动失效并重取相关查询(Invalidations from Mutations)
本指南面向使用 @tanstack/preact-query 的开发者,讲解为什么 Mutation 成功之后通常需要联动失效(invalidate)相关查询,并给出在 useMutation 回调中调用 QueryClient.invalidateQueries 的标准写法与多查询失效策略。读完你将掌握 Mutation 生命周期回调的接线方式、onSuccess 中返回 Promise 的时序语义,以及 invalidateQueries 从"标记失效"到"自动重取"的底层实现原理。
在 Preact Query(本仓库中对应 packages/preact-query)中,"查询(Query)负责读、Mutation 负责写"是服务端状态管理的核心分工。写操作成功后,应用往往需要让"依赖同一份数据"的查询感知到变化,这正是本指南要解决的核心问题。
为什么 Mutation 成功后通常要失效查询
失效查询只是解决问题的"一半",另一半是弄清楚应该在什么时候去失效。经验法则是:当应用中的某个 Mutation 成功执行后,极大概率存在与之相关的查询需要被失效并重取,以反映 Mutation 带来的最新数据变化。
例如,假设我们有一条"新增 todo"的 Mutation:
const mutation = useMutation({ mutationFn: postTodo })
当一个 postTodo Mutation 成功之后,我们通常希望所有 todos 相关的查询都被失效并可能重取,以便列表中出现新建的 todo。为此,可以在 useMutation 的 onSuccess 选项中调用 QueryClient 的 invalidateQueries 方法(Preact 示例中导入自 @tanstack/preact-query):
import { useMutation, useQueryClient } from '@tanstack/preact-query'
const queryClient = useQueryClient()
// 当这个 mutation 成功时,失效任何 queryKey 前缀为 `todos` 或 `reminders` 的查询
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: async () => {
// 如果要失效单个查询
await queryClient.invalidateQueries({ queryKey: ['todos'] })
// 如果要失效多个查询
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['todos'] }),
queryClient.invalidateQueries({ queryKey: ['reminders'] }),
])
},
})
其中 useQueryClient 读取的是组件树中 QueryClientProvider 提供的客户端实例。Preact 适配层在 QueryClientProvider.tsx 中通过 createContext + useContext 实现:若没有传入显式的 queryClient 参数且组件树上不存在 Provider,会直接抛出 No QueryClient set, use QueryClientProvider to set one。因此上述代码中的 queryClient 一定来自某个 QueryClientProvider client={queryClient} 包裹的祖先组件。
触发失效的三种接入点:onSuccess / onError / onSettled
useMutation 的所有生命周期回调都可以用来接线失效逻辑(详见 useMutation 的实现 与 mutations 指南):
| 回调 | 触发时机 | 典型用途 |
|---|---|---|
onMutate(variables, context) |
Mutation 即将开始(mutationFn 执行前) | 取消进行中的查询、写入乐观数据 |
onSuccess(data, variables, context) |
Mutation 成功 | 失效查询、写入真实数据(setQueryData) |
onError(error, variables, context) |
Mutation 失败 | 回滚乐观更新、清理数据 |
onSettled(data, error, variables, context) |
无论成功还是失败,都必定触发 | 收尾性的失效/重取 |
其中 onError 与 onSettled 在"写失败后仍需要刷新缓存"(例如别的客户端可能已经改了数据)时非常有用;而 onSettled 因为成功失败都会执行,适合放置"无论如何都重新同步一次列表"的逻辑。关于用 onMutate/onError/onSettled 组合实现"先乐观更新、失败回滚、最终失效"的完整写法,可参考 optimistic-updates.md,其核心骨架如下:
const addMutation = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previousTodos = queryClient.getQueryData<Array<string>>(['todos'])
queryClient.setQueryData<Array<string>>(['todos'], (old) => [
...(old ?? []),
newTodo,
])
return { previousTodos } // 传给 onError 用于回滚
},
onError: (_err, _newTodo, onMutateResult) => {
queryClient.setQueryData(['todos'], onMutateResult?.previousTodos)
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
onSuccess 返回 Promise 的时序语义
重要:在
onSuccess中返回一个 Promise(即写成async函数并await内部的invalidateQueries),可以确保相关数据在 Mutation 完全结束之前完成更新——也就是说,在onSuccess被 fulfill 之前,Mutation 的isPending一直为true。
这一行为并非框架巧合,而是有明确的源码保证。在 query-core 的 mutation.ts 中,成功路径的执行顺序是:
- 执行
mutationCache.config.onSuccess(可选); await this.options.onSuccess?.(...)——Mutation 选项上的onSuccess会被真正 await;await this.options.onSettled?.(...);- 最后才
this.#dispatch({ type: 'success', data }),把 Mutation 自身状态推进为success。
也就是说,dispatch('success') 被有意安排在 onSuccess/onSettled 全部 resolve 之后。所以只要 onSuccess 内 await queryClient.invalidateQueries(...),UI 上就会呈现"按钮保持 pending,直到列表重取完成",从而避免"数据还没刷新就显示完成"的闪烁。
此外,调用 mutate 时还可以传入仅对本次调用生效的额外回调:
mutate(todo, {
onSuccess: (data, variables, context) => {
// 本次调用专属的成功处理(如关闭弹窗、跳转),在 useMutation 级 onSuccess 之后触发
},
})
注意这些"调用级回调"只在对应那次调用期间且组件仍挂载时触发;若连续多次 mutate,钩子级回调每次都会执行,而调用级回调仅对最后一次调用触发。若希望每次调用都能拿到独立的 Promise,请改用 mutateAsync。
invalidateQueries 底层原理:先标记失效,再按需重取
invalidateQueries 的语义在 query-core 中实现得非常清晰,位于 queryClient.ts:
invalidateQueries(filters, options) {
return notifyManager.batch(() => {
this.#queryCache.findAll(filters).forEach((query) => {
query.invalidate()
})
if (filters?.refetchType === 'none') {
return Promise.resolve()
}
return this.refetchQueries({
...filters,
type: filters?.refetchType ?? filters?.type ?? 'active',
}, options)
})
}
整个过程分三步:
- 批量匹配:整个操作被包在
notifyManager.batch中(见 queryClient.ts),所有匹配/失效/重取通知会被合并在一次批处理里分发,避免多次触发渲染。 - 逐个标记失效:对每个匹配查询调用
query.invalidate()。在 query.ts 中,该方法在state.isInvalidated尚未置位时派发{ type: 'invalidate' }动作,把查询标记为"已失效(invalidated)"。 - 按需重取:默认会继续调用
refetchQueries立即重取。默认的重取范围是active(有活动观察者的查询),除非显式指定type或refetchType。未被立即重取的非活动查询会带着失效标记保留,待其再次成为活动查询、窗口重新聚焦或满足 stale 条件时再重取。
refetchQueries 本身(见 queryClient.ts)默认 cancelRefetch: true,即若查询正在请求中会先取消再重取;同时会跳过 isDisabled() 与 isStatic() 的查询,并把处于 paused(如离线)的请求直接视为完成。
过滤参数与重取控制详解
invalidateQueries 的第一个参数是 InvalidateQueryFilters(继承自 QueryFilters),第二个参数是 InvalidateOptions。这两组类型在 query-core 源码中有准确定义:
- utils.ts 定义
QueryFilters; - types.ts 定义
InvalidateQueryFilters extends QueryFilters与InvalidateOptions extends RefetchOptions。
核心字段说明如下:
| 字段 | 类型 | 默认 | 作用 |
|---|---|---|---|
queryKey |
TQueryKey | 前缀元组 |
— | 按 queryKey 前缀匹配查询;['todos'] 会命中 ['todos']、['todos', 1]、['todos', 'list'] 等以它为前缀的键 |
exact |
boolean |
false |
为 true 时要求 queryKey 完全相等才命中(partialMatchKey 前缀匹配失效) |
type |
'all' | 'active' | 'inactive' |
— | 参与"立即重取"的查询范围(active = 当前有活跃观察者) |
refetchType |
'all' | 'active' | 'inactive' | 'none' |
取 type ?? 'active' |
单独控制"重取哪一类";'none' 表示只标记失效、不立即重取 |
predicate |
(query) => boolean |
— | 自定义匹配函数,无法用 queryKey 表达的场景使用 |
stale |
boolean |
— | 只包含/排除 stale 查询 |
fetchStatus |
'fetching' | 'paused' | 'idle' |
— | 按当前抓取状态过滤 |
而第二个参数 InvalidateOptions extends RefetchOptions 支持:
cancelRefetch?: boolean——true(默认)先取消正在进行的请求再重取;false则在已有请求运行时跳过重取;throwOnError?: boolean——重取失败时是否抛出错误(默认静默吞掉)。
因此,如果你只想"让数据过期"而把重取推迟到查询再次被观察时执行,可以写:
await queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'none', // 只失效,不立即重取
})
要精确地只失效某一条 todo 的详情查询,则可配合 exact: true:
await queryClient.invalidateQueries({
queryKey: ['todo', todoId],
exact: true,
})
从"失效一条"到"失效一片":前缀匹配与批量失效
queryKey 默认按前缀匹配意味着:一次 invalidateQueries({ queryKey: ['todos'] }) 就能让"所有以 ['todos'] 开头的查询"(列表、详情、分页、无限滚动)统一失效——这正是"新增一条 todo 后整片列表都应刷新"这一需求的精确映射。相关键规则见 query-keys.md。
当一次写操作波及多组独立数据(例如新增 todo 同时会改变提醒数量)时,可按需逐组失效,也可以并行执行:
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['todos'] }),
queryClient.invalidateQueries({ queryKey: ['reminders'] }),
])
},
由于 invalidateQueries 内部通过 notifyManager.batch 合并通知,批量调用不会带来额外的渲染放大;而逐组 await 保证每组重取都完成后再推进 Mutation 生命周期(配合上文"dispatch success 在回调之后"的时序,组件能把控加载状态)。
与写入缓存(setQueryData)方案的取舍
失效重取并非 Mutation 后同步数据的唯一手段。官方还提供了另一条路径:直接用 Mutation 返回的数据更新缓存(queryClient.setQueryData),即 updates-from-mutation-responses.md 讲解的"基于 Mutation 响应更新缓存"。
两者的适用场景差异可以概括为:
- 失效重取(本指南):Mutation 返回体往往只是"服务端确认",无法简单映射到列表里的每一处(排序、分页、聚合都可能变化)。此时信任服务端、以
invalidateQueries触发一次干净的重取是更稳妥的选择,代价是额外一次网络请求。 - 直接写缓存(setQueryData):Mutation 返回的就是目标实体的完整新数据,且你能低成本地把它合并进现有列表。此时写缓存可省去重取,体验更即时,但要求你对缓存结构与服务端返回有强约定。
在实践中两者常结合使用:小对象用 setQueryData 即时落位,大范围列表用 invalidateQueries 兜底。由于失效发生在 Mutation 成功回调内,写缓存与失效的编排都集中在同一处 Mutation 定义里,逻辑可读性较高。
一份可运行的完整示例
把上述要点组合起来,一个典型"新增 Todo"场景在 Preact 中长这样(注意 onSuccess 用 async + await,确保 Mutation 的 isPending 覆盖到重取完成):
import { useMutation, useQueryClient } from '@tanstack/preact-query'
function AddTodo() {
const queryClient = useQueryClient()
const addMutation = useMutation({
mutationFn: addTodo,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['todos'] })
},
onError: (error) => {
console.error('添加失败:', error)
},
})
return (
<div>
{addMutation.isPending ? (
'正在添加…'
) : addMutation.isError ? (
<span>出错了:{addMutation.error.message}</span>
) : null}
<button
onClick={() => addMutation.mutate('新任务')}
disabled={addMutation.isPending}
>
添加 Todo
</button>
</div>
)
}
更进一步
- 若想深入理解
onSuccess/onError/onSettled的完整签名与"连续 Mutation 只触发一次调用级回调"等边界行为,阅读 mutations.md。 - 本指南聚焦"Mutation 成功之后失效",而失效机制的完整(含失效过滤器、
refetchType、后台重取差异)见 query-invalidation.md。 - 相关 API 完整定义见 QueryClient 参考文档 中
invalidateQueries、refetchQueries、setQueryData等条目。 - 想研究底层实现,可对照阅读 queryClient.ts、query.ts 与 mutation.ts,并参考 useMutation.ts 中内置的失效示例注释。
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