TanStack Query 服务端状态管理
TanStack Query 的核心用法:把服务端数据作为客户端投影进行缓存与同步,理解 queryKey 身份、查询生命周期、stale/fetching、Mutation 与 Zustand 的 ownership 边界。
[!info] related notes
- 所属 MOC: TanStack Query 知识地图
- 背景: TanStack Query 起源与概述
- 并列: Zustand 全局状态
- 深入: TanStack Query 缓存失效模式
- 深入: TanStack Query 与 SSE 流式数据集成
- 迁移: 从 useState 迁移到 TanStack Query
- 模式: queryKey Factory 模式
- 架构: 四层状态架构
- 实践: BodySense 项目 MOC
TanStack Query 服务端状态管理
核心问题
从 API 获取的数据有独特的生命周期问题:它可能过期、可能被其他客户端修改、需要缓存、去重、后台刷新和错误状态。TanStack Query 解决的是客户端如何同步服务端状态,不是把客户端缓存升级成新的业务真相。
最重要的 ownership 心智模型
判断一个状态属于谁,不要先问“它现在存在 Query、Zustand 还是组件变量里”,而要问:
如果两份副本冲突,谁有资格决定哪一份是真的?
例如:
Server / DB: BodyState revision = 43
TanStack Query cache: revision = 42
这里 42 即使存在 Query Cache 中,也只是 stale projection。真正 durable truth 仍由服务器拥有。
因此:
Server / durable domain
= authoritative fact owner
TanStack Query cache
= client-side projection / cache of server state
同一个值可以同时出现在多层,值相同不代表 ownership 相同。
核心模型
QueryClient = 应用级查询/Mutation 协调器
QueryCache = 按 queryKey 保存的客户端内存缓存/投影
Query = 一份远程数据的身份 + 获取方式 + 生命周期
Mutation = 请求服务器改变远程事实的操作
- QueryClient:管理 Query Cache、查询状态、Mutation 状态、订阅与生命周期。
- QueryCache:客户端缓存,不是数据库,也不是 durable truth。
- Query:主要由
queryKey、queryFn、staleTime等定义。 - Mutation:执行写操作,并在成功/失败后协调 cache。
useQuery 运行原理
组件调用 useQuery
↓
按 queryKey 找 cache entry
↓
├─ 有可用缓存 → 可以先显示 data
│ └─ 如果需要 refetch → 后台执行 queryFn
│ → 更新 cache
│ → 所有订阅者得到新结果
└─ 没有可用数据 → pending/fetch
→ queryFn
→ 写入 cache
同一个 queryKey 的多个消费者共享同一个 Query/cache entry;当同一 Query 已有 in-flight fetch 时,TanStack Query 通常共享该进行中的请求,而不是让每个组件各维护一套独立 fetch 生命周期。
queryKey:远程数据的客户端身份
useQuery({
queryKey: ['conversation', id],
queryFn: () => fetchConversation(id),
});
最稳的心智模型:
queryKey是这份远程数据在客户端 cache 中的地址/身份。
所有会改变 queryFn 结果身份的参数,都应该进入 key。
// ❌ A、B 会被 TanStack Query 当成同一个 Query
function userQuery(id: string) {
return useQuery({
queryKey: ['user'],
queryFn: () => fetchUser(id),
});
}
// ✅ 身份完整
queryKey: ['user', id]
错误 key 通常不会“自动报业务错误”,而是产生 cache aliasing / cache collision:A 的数据可能暂时或最终出现在 B 的消费者中。
stale 不等于没有数据
stale 表示:
这份缓存已经不再享有“足够新”的保证,可以在合适条件下重新同步。
它不表示:
cache 被删除
或
data 一定是 undefined
因此完全可能:
data exists
isPending = false
isFetching = true
也就是旧缓存继续渲染,同时后台 refetch。
isPending vs isFetching
isPending
≈ 当前 Query 还没有成功可用的数据状态
isFetching
≈ 当前确实有 fetch 正在执行
典型 UI:首次没有数据时展示 skeleton;已有数据后台刷新时继续显示旧数据,只加轻量同步提示。
staleTime:Freshness Policy,不是 Truth Policy
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 5 * 60 * 1000,
});
staleTime 只决定 TanStack Query 多久把缓存视为 fresh。它不证明服务器期间没有变化,也不会让缓存变成业务 authority。
即使 staleTime 很长,业务写入仍可以通过 invalidateQueries 主动让相关 Query stale。
enabled:是否允许这个 Query 自动执行
useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id!),
enabled: !!id,
});
适合路由身份未就绪、依赖查询尚缺参数等情况。enabled 解决执行条件,不改变 Query 的身份语义。
Mutation 后如何同步
const mutation = useMutation({
mutationFn: updateProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
核心不是“写完一定 invalidate”,而是看客户端拥有什么证据:
Mutation 返回完整、权威的新 resource
→ 可以考虑 setQueryData
Mutation 只返回局部确认,或会影响多个未知 projection
→ invalidate/refetch 更稳
如果服务器返回 409 stale revision,这本身就是本地 projection 已经过期的直接证据,通常应该 refetch authoritative state。
乐观更新
乐观更新是客户端先预测成功结果,再等待服务器确认:
const mutation = useMutation({
mutationFn: updateProfile,
onMutate: async (next) => {
await queryClient.cancelQueries({ queryKey: ['profile'] });
const previous = queryClient.getQueryData(['profile']);
queryClient.setQueryData(['profile'], next);
return { previous };
},
onError: (_err, _next, context) => {
queryClient.setQueryData(['profile'], context?.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] });
},
});
这是一种 UX/一致性策略,不意味着乐观值成为 durable truth。
与 Zustand 的分工:按 owner/lifetime,而不是按容器名字
常见情况:
Local component state
→ dialog open、accordion、未提交输入
URL state
→ 当前资源 ID、分页/筛选等可导航身份
Shared client presentation state
→ sidebar collapsed、主题、跨组件 UI workflow
TanStack Query
→ server-owned data 的 client cache/projection
Server durable domain
→ 真正业务事实、revision、权限状态
不能简化成:
来自 API → 一定只能放 TanStack Query
客户端产生 → 一定放 Zustand
例如 /api/me 返回的用户信息即使物理上暂存在 Zustand,它的业务 ownership 仍属于服务端;Zustand 只是 projection/container。反过来,localStorage 持久化也只是一种客户端 storage policy,不会自动证明某状态该由 Zustand 拥有。
不是 Normalized Cache
TanStack Query 不会像 Apollo normalized cache 那样自动按实体 ID 将所有 Query 中的同一实体合并。
['conversations']
→ list 中有 conversation abc
['conversation', 'abc']
→ detail 中也有 conversation abc
两者是独立 cache entry。Mutation 后需要精确更新或 invalidate 所有受影响 projection。
BodySense 对应心智模型
Health Workspace 当前采用:
queryKey: ['workspace', 'health']
queryFn: workspaceApi.get
staleTime: 10s
客户端缓存里的 current_revision 是服务器 revision 的 projection;写命令携带 expected_revision。如果服务器已经前进到更高 revision,会由服务端 OCC/领域逻辑拒绝 stale write,而不是让 Query Cache 反过来覆盖事实。
60 秒记忆
queryKey = 远程数据的客户端身份
queryFn = 怎么取得它
Query Cache = 当前知道的服务器数据副本
staleTime = 多久以内认为副本足够新
isFetching = 当前是否正在同步
invalidate = 我不再信它足够新,请重新同步
最重要:
Query Cache = projection, not durable truth
常见错误
- 把 Query Cache 当成新的业务事实层。
queryFn使用了id/filter/page,却没把它们放进queryKey。- 把
isFetching当成“页面完全没有数据”,导致后台刷新时整页闪 loading。 - 把 server data 再复制到 Zustand/useState 并独立修改,形成双真值。
- Mutation 返回的信息不足,却在客户端手工猜完整新 state。
学习检查
不看正文回答:
- TanStack Query 管理的 server state 为什么只是服务器事实的客户端 projection,而不是新的 truth source?
queryKey为什么可以理解为远程数据在客户端的身份?- stale 与“没有数据”有什么区别?为什么后台 refetch 时不应总显示整页 loading?
staleTime决定的是 freshness policy 还是业务真值?- Mutation 完成后,什么时候可以直接写 cache,什么时候更适合 invalidate / refetch?
- 为什么把同一份 server data 再复制进 Zustand / useState 会产生双真值风险?
- optimistic update 的前提和回滚责任是什么?
- 给一份新状态,先判断 authority / lifetime,再决定它属于 Query、组件 state 还是客户端 store。