TanStack Query 服务端状态管理
TanStack Query 的核心用法:useQuery 缓存 API 数据、useMutation 处理写操作、queryKey 设计、乐观更新、与 Zustand 的分工。
#type / howto
#status / growing
#tech / dev / frontend
#resource / react
[!info] related notes
- 所属 MOC: TanStack Query 知识地图
- 背景: TanStack Query 起源与概述
- 并列: Zustand 全局状态
- 深入: TanStack Query 缓存失效模式
- 深入: TanStack Query 与 SSE 流式数据集成
- 迁移: 从 useState 迁移到 TanStack Query
- 模式: queryKey Factory 模式
- 架构: 四层状态架构
- 实践: BodySense 项目 MOC
TanStack Query 服务端状态管理
核心问题
从 API 获取的数据有独特的生命周期问题:数据可能过期、可能被其他客户端修改、需要缓存避免重复请求、需要 loading/error 状态。用 useEffect + useState 手动管理这些问题会导致大量样板代码和 bug。
核心模型
QueryClient = 整个应用的数据管家
QueryCache = 服务端数据缓存数据库
Query = 一次"读数据"的资源描述
Mutation = 一次"写数据/改数据"的操作描述
- QueryClient:应用级单例,管理所有 query 缓存、mutation 状态、组件订阅和缓存生命周期。见 QueryClient 配置策略
- QueryCache:QueryClient 内部的缓存存储,按 queryKey 组织
- Query:由
useQuery创建,包含 queryKey(身份)、queryFn(获取方式)、staleTime/gcTime(生命周期) - Mutation:由
useMutation创建,包含 mutationFn(执行操作)和 onSuccess/onError/onSettled(副作用)
useQuery 运行原理
组件渲染
↓
调用 useQuery({ queryKey, queryFn })
↓
TanStack Query 去 QueryCache 里找这份 queryKey
↓
├─ 有缓存 → 先返回缓存数据(data 有值)
│ └─ 缓存是 stale?→ 后台发起 queryFn
│ └─ 请求完成 → 更新缓存 → 所有订阅者 re-render
└─ 没缓存 → isPending = true → 发起 queryFn
└─ 请求完成 → 写入缓存 → isPending = false
关键行为:
- 去重:多个组件同时用同一个 queryKey,只发一次请求
- 共享:所有订阅同一个 queryKey 的组件共享同一份缓存
- 自动 refetch:queryKey 变化、窗口聚焦、网络重连时自动重新获取
核心概念
queryKey — 缓存键
useQuery({ queryKey: ['consultations'], queryFn: fetchList });
useQuery({ queryKey: ['consultation', id], queryFn: () => fetchOne(id) });
- 相同 key 共享缓存
- key 变化时自动重新获取
- key 要包含所有影响结果的参数
staleTime — 数据新鲜度
useQuery({
queryKey: ['profile'],
queryFn: fetchProfile,
staleTime: 5 * 60 * 1000, // 5 分钟内不重新请求
});
- 0(默认):每次组件挂载都重新请求
- Infinity:永远不自动重新请求(手动 invalidate)
- 中间值:窗口期内用缓存,过期后后台刷新
enabled — 条件请求
useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
enabled: !!id, // id 有值时才请求
});
enabled: false时 queryFn 不会执行,query 状态保持 pending- 常用于:依赖查询、URL 参数未就绪时、SSE 活跃期间禁用自动 refetch
- 配合空值 key 使用更干净,见 queryKey Factory 模式
useMutation — 写操作
const mutation = useMutation({
mutationFn: (message: string) => sendMessage(sessionId, message),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['consultation', sessionId] });
},
});
写操作后使相关缓存失效,触发重新获取。
状态模型:isPending vs isFetching
useQuery 返回的状态不止 loading/error/data。最易混淆的是:
isPending → 这份 query 还没有可用数据(首次加载中)
isFetching → 正在请求,包括后台刷新
| 场景 | isPending | isFetching | data |
|---|---|---|---|
| 首次加载,无缓存 | true | true | undefined |
| 有缓存,后台刷新中 | false | true | 旧数据 |
| 缓存新鲜,不请求 | false | false | 缓存数据 |
| 请求失败,无缓存 | true | false | undefined |
// ✅ 正确用法:区分首次加载和后台刷新
if (conversationQuery.isPending) {
return <Skeleton />; // 整页骨架屏
}
// 后台刷新时只显示小提示,不阻断 UI
return (
<div>
{conversationQuery.isFetching && <SyncIndicator />}
<ConversationView data={conversationQuery.data} />
</div>
);
// ❌ 错误:用 isFetching 做整页 loading
if (conversationQuery.isFetching) {
return <Loading />; // 后台刷新时也会整页 loading,体验差
}
queryKey 设计原则
// ✅ 包含所有影响结果的参数
useQuery({ queryKey: ['users', { page, limit, role }], queryFn: ... });
// ❌ 参数变化但 key 没变,不会重新获取
useQuery({ queryKey: ['users'], queryFn: () => fetchUsers(page, limit) });
乐观更新
先更新 UI,等服务器确认(或回滚):
const mutation = useMutation({
mutationFn: updateProfile,
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['profile'] });
const previous = queryClient.getQueryData(['profile']);
queryClient.setQueryData(['profile'], newData); // 立即更新
return { previous };
},
onError: (err, newData, context) => {
queryClient.setQueryData(['profile'], context.previous); // 回滚
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['profile'] }); // 最终同步
},
});
与 Zustand 的分工
Zustand: 认证 token、UI 状态、表单草稿
TanStack Query: 会话列表、评估报告、训练计划、用户档案
简单判断:数据来自 API → TanStack Query;数据在客户端产生 → Zustand。
不是 Normalized Cache
TanStack Query 不是 Apollo Client 那种规范化缓存。它不会把对象按 id 拆成实体表,也不会自动跨 query 同步同一实体。
Apollo 的思路:
Conversation:abc → { id: 'abc', title: 'Hello' }
所有引用 Conversation:abc 的 query 自动同步
TanStack Query 的思路:
['conversations'] 缓存里有一份 title
['conversation', 'abc'] 缓存里也有一份 title
两份独立,不会自动同步
这意味着:同一个字段(如 title)可能同时存在于 list cache 和 detail cache 里。mutation 后需要同时更新两处,或者分别 invalidate。
// 重命名后,两处缓存都要更新
queryClient.setQueryData(consultationKeys.conversations(), updateList);
queryClient.setQueryData(consultationKeys.conversation(id), updateDetail);
// 或者分别 invalidate(更简单但多一次请求)
queryClient.invalidateQueries({ queryKey: consultationKeys.conversations() });
queryClient.invalidateQueries({ queryKey: consultationKeys.conversation(id) });
这不是缺陷,是设计选择。只要 queryKey 清晰、mutation 同步策略明确,就是可控的。
常见错误
useEffect + useState 手动 fetch
// ❌ 手动管理
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []);
// ✅ 用 useQuery
const { data } = useQuery({ queryKey: ['data'], queryFn: () => fetch('/api/data').then(r => r.json()) });
不处理错误
// ❌ 忽略错误
const { data } = useQuery({ ... });
// ✅ 处理错误
const { data, error, isLoading } = useQuery({ ... });
if (error) return <ErrorMessage error={error} />;