关于 accelint-tanstack-query-best-practices
accelint-tanstack-query-best-practices 是面向使用 TanStack Query 构建 React 应用的开发者技能,涵盖 Next.js App Router 和 Server Components。它按场景提供查询配置、缓存设计、变更处理与性能诊断指导。
主要功能
- 01QueryClient 配置指导
- 02稳定的查询键模式
- 03缓存与失效策略
- 04乐观和悲观变更模式
- 05Observer 性能诊断
适用场景
- 01配置 TanStack Query 的服务端与客户端
- 02设计查询键和缓存生命周期
- 03为操作选择变更处理方式
- 04诊断过多的查询 Observer
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ gohypergiant/ agent-skills --skill accelint-tanstack-query-best-practices安装后在 Claude Code 中输入/accelint-tanstack-query-best-practices确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
assets/output-report-template.mdquery-client.tsreferences/caching-strategy.mdfundamentals.mdmutations-and-updates.mdpatterns-and-pitfalls.mdquery-client-setup.mdquery-keys.mdserver-integration.md详细介绍
TanStack Query 最佳实践
面向现代 React 应用的 TanStack Query 专家模式,适用于 Next.js App Router 和服务器组件。
使用 TanStack Query 时绝对不要做的事
- 绝对不要在服务端使用单例 QueryClient - 这会导致用户间数据泄露和竞争条件。每个请求都必须使用独立的 QueryClient 实例,以防一个用户的缓存数据出现在另一个用户面前。
- 绝对不要将查询数据同步到 useState - 后台重新获取、失效处理和乐观更新都会修改缓存。本地状态副本会立即过时,导致“我的保存没生效”之类的问题。直接使用查询数据,或通过 useMemo 派生数据。
- 绝对不要在列表项组件内放置查询 - 这会为 N 个列表项创建 N 个观察者,导致每次缓存更新都进行 O(n) 遍历。200 个列表项调用 useQuery 会创建 200 个网络请求和 200 个观察者。将查询提升到父组件中。
- 绝对不要使用不稳定的查询键 - 顺序无法保证的数组、使用 Date.now() 的时序查询,或缺少确定性序列化的对象键,都会创建无限的缓存条目。键必须稳定且确定。
- 绝对不要跳过依赖查询的 enabled 守卫 - 使用 undefined 参数发起查询会创建诸如 ['tracks', undefined] 的无效缓存条目,并在真实数据到达前浪费网络请求。
- 绝对不要忽略 AbortController 信号 - 如果不支持取消查询,已卸载的组件会让正在进行的请求继续运行,浪费带宽,还可能更新过时的缓存条目。
- 绝对不要对高风险或外部触发的变更使用乐观更新 - 关乎生命安全的操作、审计跟踪系统,以及由外部事件触发的变更,都需要采用悲观更新,确保 UI 与服务器状态一致。
- 绝对不要假设结构共享没有开销 - 对于频繁更新且超过 1000 个条目的数据集,结构共享的 O(n) 深度相等性检查会增加 CPU 开销。对于大型且频繁变化的数据,使用 structuralSharing: false 禁用结构共享。
- 绝对不要在乐观更新中跳过 onSettled - 即使 onError 抛出错误,onSettled 也是你的清理保障。如果错误处理器失败而没有 onSettled,UI 可能会处于损坏状态。始终将 onMutate 与 onSettled 配对使用,以清理资源并保持缓存一致性。
- 绝对不要假设缓存失效是同步的 - invalidateQueries 会触发后台重新获取,这可能与乐观更新发生竞争。在 onMutate 中使用 cancelQueries,防止后台重新获取在变更完成前覆盖你的乐观更改。
- 绝对不要不经结构比较就使用 setQueryData - 直接设置缓存数据会绕过结构共享,破坏引用相等性优化。使用更新函数包装,以保留未更改部分的引用:使用
setQueryData(key, (old) => ({ ...old, changed: value })),而不是setQueryData(key, newValue)。 - 绝对不要忘记处理水合不匹配 - 服务端渲染的数据可能与客户端预期不同(时间戳、用户专属数据、随机内容)。对容器使用 suppressHydrationWarning,或通过稳定时间戳和一致的数据源确保服务端与客户端渲染具有确定性。
使用 TanStack Query 前,请先自问
状态分类
- 这是服务端状态还是客户端状态? TanStack Query 管理服务端状态(API 数据、数据库记录、外部系统状态)。UI 状态(模态框、主题、表单草稿)应由 Zustand 或 useState 管理。
- 此数据在初始渲染后会变化吗? 静态参考数据可能不需要 TanStack Query 的重新获取机制。考虑一下更简单的替代方案是否足够。
缓存策略
- 此数据需要多新? 查找表可以设置 1 小时的 staleTime。实时跟踪需要 5 秒的 staleTime 和 refetchInterval。根据业务需求匹配配置。
- 查询的生命周期是什么? 经常访问的数据需要更高的 gcTime。一次性详情视图可以采用积极的垃圾回收策略。
观察者开销
- 有多少组件会订阅此查询? 单个缓存条目有超过 10 个观察者,说明应将查询提升到父级。超过 100 个观察者则表明存在架构问题。
- 我创建的是 N 个查询,还是 1 个有 N 个观察者的查询? 列表项应接收父级查询传入的属性,而不是分别调用各自的 useQuery 钩子。
使用方法
此技能采用渐进式披露来尽量减少上下文占用。根据你的场景加载相关参考资料:
场景 1:设置查询客户端
必须:完整阅读 query-client-setup.md(约 125 行)和 server-integration.md(约 151 行),了解服务端和客户端设置模式。
不要加载其他参考资料进行初始设置。
复制 assets/query-client.ts,使用可用于生产环境的配置。
场景 2:构建查询钩子
- **必须:**阅读
query-keys.md(约 151 行),了解键工厂设置 - 如果使用服务端组件:阅读
server-integration.md - 除非要实现变更,否则不要加载 mutations-and-updates.md
使用下方的决策表设置配置值。
场景 3:实现变更
必须:完整阅读 mutations-and-updates.md(约 345 行)。回滚模式请参考 patterns-and-pitfalls.md。
基础 CRUD 变更不要加载 caching-strategy.md。
场景 4:调试性能问题
- 首先查看下方的观察者数量阈值表(第 121-129 行)
- 如果观察者数量 >50:阅读
patterns-and-pitfalls.md - 如果遇到大型数据集问题:阅读
fundamentals.md,了解结构共享 - 不要加载所有参考资料——先诊断,再加载相关内容
场景 5:多层缓存策略
**必须:**阅读 caching-strategy.md(约 198 行),了解统一的 Next.js use cache + TanStack Query + HTTP 缓存模式。
如果只使用客户端 TanStack Query,不要加载此文件。
查询配置决策矩阵
| 数据类型 | staleTime | gcTime | refetchInterval | structuralSharing | 备注 |
|---|---|---|---|---|---|
| 参考数据/查找数据 | 1hr | Infinity | - | true | 国家、类别、静态枚举 |
| 用户资料 | 5min | 10min | - | true | 变化不频繁,保持适度新鲜度 |
| 实时跟踪 | 5s | 30s | 5s | false | 更新频率高,负载量大 |
| 实时仪表盘 | 2s | 1min | 2s | 视大小而定 | 平衡新鲜度与性能 |
| 详情视图 | 30s | 2min | - | true | 按需获取,适度缓存 |
| 搜索结果 | 1min | 5min | - | true | 可缓存,对时效性要求不高 |
变更模式选择
| 场景 | 模式 | 使用时机 |
|---|---|---|
| 表单提交 | 悲观 | 多步骤表单、需要服务端验证,或继续操作前需要查看错误消息 |
| 切换/复选框 | 乐观 | 二元状态变化、需要低延迟,且易于回滚 |
| 拖放 | 乐观 | 需要即时视觉反馈、重排序操作、非关键数据 |
| 批量操作 | 悲观 | 涉及多个项目、可能部分失败,或用户需要确认哪些操作成功 |
| 攸关生命的操作 | 悲观 | 医疗、金融、安全关键系统,界面必须与服务端实际状态一致 |
| 需要审计跟踪 | 悲观 | 合规系统中,操作员的操作必须与记录的事件完全一致 |
查询键架构
使用分层工厂实现一致的失效处理:
// Recommended structure
export const keys = {
all: () => ['domain'] as const,
lists: () => [...keys.all(), 'list'] as const,
list: (filters: string) => [...keys.lists(), filters] as const,
details: () => [...keys.all(), 'detail'] as const,
detail: (id: string) => [...keys.details(), id] as const,
};
// Invalidation examples
queryClient.invalidateQueries({ queryKey: keys.all() }); // Invalidate everything
queryClient.invalidateQueries({ queryKey: keys.lists() }); // Invalidate all lists
queryClient.invalidateQueries({ queryKey: keys.detail(id) }); // Invalidate one item
键稳定性规则:
- 使用确定性序列化(拼接数组前先排序)
- 不使用时间值(Date.now()、随机 ID)
- 保持类型一致(不要混用 '1' 和 1)
- 保持对象结构稳定(使用已排序的键或进行序列化)
服务端与客户端集成模式
| 层 | 用途 | 失效方法 | 缓存范围 |
|---|---|---|---|
| Next.js use cache | 降低数据库负载 | revalidateTag() 或 updateTag() | 跨请求,服务端 |
| TanStack Query | 客户端状态管理 | queryClient.invalidateQueries() | 每个浏览器标签页 |
| 浏览器 HTTP 缓存 | 消除网络请求 | Cache-Control 标头 | 每个浏览器 |
统一失效策略:
- 服务端和客户端缓存使用相同的键工厂
- 服务端变更调用 updateTag(keys.detail(id).tag)
- 客户端变更调用 queryClient.invalidateQueries({ queryKey: keys.detail(id) })
- 两种缓存使用相同的层级结构,保持同步
观察者数量阈值
| 观察者数量 | 性能影响 | 所需操作 |
|---|---|---|
| 1-5 | 可忽略 | 无 |
| 6-20 | 极小 | 监控,无需立即操作 |
| 21-50 | 更新时可察觉 | 考虑将查询提升到父组件 |
| 51-100 | 开销显著 | 重构:将查询提升到父组件或使用 select |
| 100+ | 影响严重 | 立即重构:使用单个查询并通过 props 分发数据 |
诊断:
- 在开发环境中打开 TanStack Query DevTools
- 找出观察者数量较多的缓存条目
- 在代码库中搜索使用这些键的 useQuery 调用
- 重构为由父组件发起查询或使用共享缓存条目
查询钩子模式
| 模式 | 使用场景 | 示例 |
|---|---|---|
| useSuspenseQuery | 集成服务端组件、Suspense 边界 | useSuspenseQuery({ queryKey, queryFn }) |
| useQuery with enabled | 依赖查询、条件式获取 | useQuery({ queryKey, queryFn, enabled: !!userId }) |
| useQuery with select | 数据转换、选择子集 | useQuery({ queryKey, queryFn, select: selectFn }) — 将 selectFn 提取为稳定的模块级变量;内联函数会在每次渲染时重新运行 |
| useMutation optimistic | 低延迟 UI 更新、易于撤销的操作 | useMutation({ onMutate, onError, onSettled }) |
| useMutation pessimistic | 影响重大的操作、服务端验证 | useMutation({ onSuccess }) |
常见错误模式及修复方法
| 症状 | 根本原因 | 解决方案 | 解决方案无效时的备用方案 |
|---|---|---|---|
| 保存后数据没有更新 | 将查询数据复制到了 useState | 直接使用查询数据,通过 useMemo 派生数据 | 使用 refetch() 方法强制重新获取数据;检查网络选项卡中的实际 API 响应 |
| 请求无限循环 | 查询键不稳定(Date.now()、未排序的数组) | 使用确定性的键构造方式 | 添加过期检测:const requestCount = useRef(0); useEffect(() => { requestCount.current++; if (requestCount.current > 10) console.error('Infinite loop detected', queryKey); }, [data]); 有关键稳定性模式,请参阅 fundamentals.md |
| 出现 N 个重复请求 | 在每个列表项中都执行查询 | 将查询提升到父级,并通过 props 传递数据 | 确保所有组件使用完全相同的 queryKey(同一对象引用或值):const queryKey = useMemo(() => keys.list(filters), [filters]); 将 staleTime 增加到 30s,以合并短时间内的重复请求 |
| 查询使用 undefined 参数执行 | 缺少 enabled 防护 | 添加 enabled: Boolean(dependency) | 使用 placeholderData 显示加载状态,并在 queryFn 中添加类型守卫以提前抛出错误 |
| 列表渲染缓慢 | N 个查询 + N 个观察者 | 使用单个父级查询,通过 props 分发数据 | 使用 select 订阅数据子集;实现虚拟滚动以减少已挂载的组件 |
| 缓存永不清除 | 对频繁变化的数据设置了 gcTime: Infinity | 根据数据生命周期设置 gcTime | 使用 queryClient.removeQueries() 强制移除;通过 DevTools 监控缓存大小 |
| UI 短暂显示过期数据 | 服务端缓存已过期,客户端缓存仍是最新的 | 使用相同的键统一进行失效处理 | 使用服务端 props 中的 initialData;对已完成水合的查询设置 refetchOnMount: false |
| 乐观更新无法回滚 | onError 未恢复 context | 在 onError 中使用 onMutate 返回的 context | 使用 invalidateQueries 强制失效;通过之前状态的快照手动实现回滚 |
| 服务端水合不匹配 | SSR 中包含时间戳或用户特定数据 | 在容器上使用 suppressHydrationWarning | 使用动态导入并设置 ssr: false,仅在客户端渲染;或将时间戳统一为 UTC |
| 查询从不重新获取 | enabled: false 防护阻止了查询,或 gcTime 已过期 | 检查 enabled 条件,并确认查询未被谓词过滤 | 增大 gcTime 以延长缓存保留时间;使用 refetchInterval 进行轮询;检查 staleTime: Infinity 是否阻止了后台重新获取 |
| 服务端 action 未使缓存失效 | updateTag/revalidateTag 使用的键与 queryClient 不同 | 服务端和客户端缓存使用相同的键工厂 | 在服务端 action 后手动调用 router.refresh();确认标签名称与查询键层级匹配 |
| Mutation 成功但 UI 未更新 | 缺少 onSuccess 失效处理,或 queryKey 错误 | 添加 onSuccess: () => queryClient.invalidateQueries({ queryKey }) | 使用 setQueryData 手动更新缓存:queryClient.setQueryData(keys.detail(id), newData);确认 queryKey 完全匹配 |
故障排除决策树
性能问题
步骤 1:在 DevTools 中检查观察者数量(使用第 136-145 行的阈值)
- >100 个观察者 → 立即重构:将查询提升到父组件,通过 props 分发数据
- 51-100 个观察者 → 重构:提升查询,或使用 select 订阅数据子集
- <50 个观察者 → 问题出在其他地方,继续步骤 2
步骤 2:检查数据量和更新频率
- >1000 项且频繁更新 → 禁用结构共享:
structuralSharing: false(详情参见 fundamentals.md) - 有效负载较大(>500KB) → 检查网络选项卡,考虑分页或无限查询
- 更新频繁(间隔 <1s) → 降低 staleTime 或使用 refetchInterval,并核实缓存策略
步骤 3:检查 React DevTools Profiler
- 查找使用查询数据的组件是否存在不必要的重新渲染
- 确认 select 函数不会在每次渲染时重新创建(使用 useCallback)
- 检查派生数据是否应使用 useMemo,而非内联转换
- 分析组件渲染耗时,找出瓶颈
网络问题
连接不稳定:
- 配置重试逻辑:
retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) - 生产环境的重试配置参见 query-client-setup.md
需要刷新令牌:
- 在 queryFn 包装器中实现身份验证拦截器模式
- 使用 queryClient.setQueryDefaults() 设置全局身份验证标头
- 令牌刷新模式参见 patterns-and-pitfalls.md
竞态条件:
- 检查失效时机:在 setQueryData 之前使用 cancelQueries
- 检查乐观更新是否与后台重新获取数据相互冲突
- 确认 mutation onMutate 使用 await cancelQueries({ queryKey })
水合问题
SSR 不匹配(控制台中出现水合错误):
- 将 suppressHydrationWarning 添加到容器元素
- 规范化数据:确保服务端和客户端生成相同的输出(稳定的时间戳、排序后的数组)
- 检查是否在服务端渲染了用户专属数据
客户端与服务端数据不一致:
- 核实服务端变更中的 revalidateTag 时机
- 检查服务端缓存(Next.js use cache)是否过期,而客户端缓存仍然是最新的
- 使用服务端 props 中的 initialData:
initialData: serverData, refetchOnMount: false
HydrationBoundary 未生效:
- 核实客户端组件边界:HydrationBoundary 必须包裹 'use client' 组件
- 检查 dehydratedState 是否已从服务端正确序列化
- 确保 shouldDehydrateQuery 包含需要水合的查询
自由度校准
根据变更风险校准指导的具体程度:
| 任务类型 | 自由度 | 指导形式 | 示例 |
|---|---|---|---|
| 查询配置 | 高 | 为常见模式提供原则和表格 | “让 staleTime 符合业务要求” |
| 乐观更新 | 中 | 提供包含回滚处理的完整模式 | “使用 onMutate/onError/onSettled 回调” |
| QueryClient 设置 | 低 | 提供准确代码并附带关键安全警告 | “服务端绝不要使用单例——应使用工厂” |
判断标准:“如果 Agent 出错,会有什么后果?”
- 服务端单例错误 → 用户之间的数据泄漏(严重安全问题)
- 观察者数量设置错误 → 性能下降(中等影响)
- staleTime 调整不当 → 数据新鲜度不理想(影响较低)
重要说明
- 查询键会以确定性方式进行哈希处理——['tracks', '1'] 和 ['tracks', 1] 会创建不同的缓存条目
- 查询键必须可序列化为 JSON,才能在重新加载页面和进行水合时持久化缓存
- 将 shouldDehydrateQuery 设置为 pending 状态,可在服务器组件中无需 await 即可进行流式传输
- HydrationBoundary 必须仅包裹客户端组件——服务器组件会绕过该边界
- revalidateTag 与 updateTag 的区别很重要:revalidateTag 使用过期后重新验证,updateTag 则会立即使缓存失效
- 只要 gcTime 尚未过期,即使没有组件处于挂载状态,后台重新获取仍会运行
- 使用 select 时,结构共享会运行两次:一次针对原始数据,一次针对转换后的数据
select只会对成功缓存的数据运行——在错误状态下绝不会调用;应将验证和错误抛出逻辑放在queryFn中- 在 onMutate 中调用 cancelQueries 至关重要——后台重新获取可能会覆盖乐观更新
- onMutate 返回的上下文会传递给 onError 和 onSettled,用于回滚状态