TanStack Query 服务端状态管理

TanStack Query 的核心用法:useQuery 缓存 API 数据、useMutation 处理写操作、queryKey 设计、乐观更新、与 Zustand 的分工。

#type / howto #status / growing #tech / dev / frontend #resource / react

[!info] related notes

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 → 正在请求,包括后台刷新
场景isPendingisFetchingdata
首次加载,无缓存truetrueundefined
有缓存,后台刷新中falsetrue旧数据
缓存新鲜,不请求falsefalse缓存数据
请求失败,无缓存truefalseundefined
// ✅ 正确用法:区分首次加载和后台刷新
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} />;
创建于 2026/6/25 更新于 2026/7/15