← [ BACK_TO_NOTES ]

TanStack Query 服务端状态管理

TanStack Query 的核心用法:把服务端数据作为客户端投影进行缓存与同步,理解 queryKey 身份、查询生命周期、stale/fetching、Mutation 与 Zustand 的 ownership 边界。

#synthesis #growing #frontend #react

[!info] related notes

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 的消费者中。

见 queryKey Factory 模式。

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。

见 gcTime vs staleTime 辨析。

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。

见 TanStack Query 缓存失效模式。

乐观更新

乐观更新是客户端先预测成功结果,再等待服务器确认:

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。

学习检查

不看正文回答:

  1. TanStack Query 管理的 server state 为什么只是服务器事实的客户端 projection,而不是新的 truth source?
  2. queryKey 为什么可以理解为远程数据在客户端的身份?
  3. stale 与“没有数据”有什么区别?为什么后台 refetch 时不应总显示整页 loading?
  4. staleTime 决定的是 freshness policy 还是业务真值?
  5. Mutation 完成后,什么时候可以直接写 cache,什么时候更适合 invalidate / refetch?
  6. 为什么把同一份 server data 再复制进 Zustand / useState 会产生双真值风险?
  7. optimistic update 的前提和回滚责任是什么?
  8. 给一份新状态,先判断 authority / lifetime,再决定它属于 Query、组件 state 还是客户端 store。
创建于 2026/6/25 更新于 2026/9/30
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本