深入理解 TanStack Query(React Query)
基础使用、缓存策略、查询生命周期、乐观更新、Infinite Query、SSR 集成与最佳实践
什么是 TanStack Query?
定义:TanStack Query(前身 React Query)是专注于服务端状态管理的库,提供数据获取、缓存、同步、后台刷新、错误重试等能力。它将服务端数据视为"缓存"而非"状态",从根本上改变了 React 应用处理异步数据的方式。
涉及场景:
- API 数据获取:替代 useEffect + useState 的手动请求模式
- 缓存与去重:多个组件请求相同数据时自动去重,只发一次请求
- 后台刷新:用户重新聚焦页面时自动刷新数据
- 分页与无限滚动:Infinite Query 支持无限加载
- 乐观更新:用户操作后立即更新 UI,请求失败时回滚
- SSR / RSC:服务端预取数据,客户端直接使用缓存
作用:
- 消除手写请求逻辑:不再需要 loading/error/data 三件套
- 自动缓存管理:staleTime + gcTime 控制数据新鲜度和生命周期
- 2026 年事实标准:替代了 Redux 中管理 API 数据的场景
- 面试热点:缓存策略、查询失效、乐观更新是常见考题
一、基础使用
安装与配置
bash
npm install @tanstack/react-query @tanstack/react-query-devtoolsjsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 数据 60 秒内视为新鲜
gcTime: 5 * 60 * 1000, // 未使用的缓存 5 分钟后回收
retry: 3, // 失败重试 3 次
refetchOnWindowFocus: true, // 窗口聚焦时重新获取
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<MainApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}useQuery —— 数据查询
jsx
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const {
data, // 查询数据
isLoading, // 首次加载中(无缓存)
isFetching, // 任何请求进行中(包括后台刷新)
isError, // 是否出错
error, // 错误对象
isSuccess, // 是否成功
isStale, // 数据是否过期
refetch, // 手动重新获取
} = useQuery({
queryKey: ['user', userId], // 缓存 key(数组格式)
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // 5分钟内视为新鲜
enabled: !!userId, // 条件查询:userId 存在时才发请求
placeholderData: (prev) => prev, // 切换 userId 时保留旧数据
select: (data) => data.profile, // 数据转换(只返回 profile 部分)
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return <div>{data.name}</div>;
}useMutation —— 数据变更
jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newPost) => fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
}).then(r => r.json()),
onSuccess: (data) => {
// 方式1:使相关查询失效(重新获取)
queryClient.invalidateQueries({ queryKey: ['posts'] });
// 方式2:直接更新缓存
queryClient.setQueryData(['posts'], (old) => [...old, data]);
},
onError: (error) => {
toast.error(`创建失败: ${error.message}`);
},
});
const handleSubmit = (formData) => {
mutation.mutate({
title: formData.get('title'),
content: formData.get('content'),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<textarea name="content" />
<button disabled={mutation.isPending}>
{mutation.isPending ? '创建中...' : '创建文章'}
</button>
{mutation.isError && <p className="error">{mutation.error.message}</p>}
</form>
);
}二、缓存策略
queryKey 设计
jsx
// queryKey 是缓存的唯一标识,数组中每个元素参与缓存 key 的计算
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
useQuery({ queryKey: ['todos', 'active'], queryFn: fetchActiveTodos });
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn: fetchTodos });
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// 层级关系(用于批量失效)
// invalidateQueries({ queryKey: ['todos'] })
// → 会失效所有以 ['todos'] 开头的查询:
// ['todos']
// ['todos', 'active']
// ['todos', { status: 'done', page: 1 }]staleTime vs gcTime
staleTime(新鲜时间):
数据在多长时间内被认为是"新鲜"的
新鲜 → 不会后台重新获取
过期 → 下次使用时触发后台重新获取
gcTime(垃圾回收时间,原 cacheTime):
查询没有活跃观察者后,缓存在内存中保留多长时间
超时后缓存被删除
时间线示例(staleTime=60s, gcTime=300s):
0s: 组件 A 发起查询 → 请求 → 缓存数据
30s: 组件 B 使用相同 key → 直接返回缓存(数据仍新鲜)
60s: 数据过期(stale)
90s: 组件 C 使用相同 key → 先返回缓存 → 后台重新获取 → 更新
120s: 所有组件卸载(无观察者)
420s: gcTime 到期 → 缓存删除
421s: 新组件查询 → 无缓存 → isLoading=true → 重新请求查询状态流转
┌──────────┐
│ fresh │ ← staleTime 内
└────┬─────┘
│ staleTime 过期
┌────▼─────┐
┌────→│ stale │ ← 下次使用时后台刷新
│ └────┬─────┘
│ │ 组件卸载(无观察者)
│ ┌────▼─────┐
│ │ inactive │ ← gcTime 倒计时
│ └────┬─────┘
│ │ gcTime 过期
│ ┌────▼─────┐
│ │ deleted │ ← 缓存清除
│ └──────────┘
│
└── refetch / invalidate → 重新获取后回到 fresh三、查询失效与重新获取
jsx
const queryClient = useQueryClient();
// 1. 失效所有查询
queryClient.invalidateQueries();
// 2. 失效特定查询(前缀匹配)
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 3. 精确匹配
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true });
// 4. 使用 predicate
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.length > 10,
});
// 5. 直接设置缓存数据
queryClient.setQueryData(['user', userId], (old) => ({
...old,
name: newName,
}));
// 6. 预取数据(如鼠标 hover 时)
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60 * 1000,
});四、乐观更新
jsx
const queryClient = useQueryClient();
const updateTodo = useMutation({
mutationFn: (updatedTodo) =>
fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PUT',
body: JSON.stringify(updatedTodo),
}),
// 乐观更新:请求发出前立即更新 UI
onMutate: async (updatedTodo) => {
// 1. 取消正在进行的查询(防止覆盖乐观更新)
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2. 保存当前数据快照(用于回滚)
const previousTodos = queryClient.getQueryData(['todos']);
// 3. 乐观更新缓存
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo)
);
// 4. 返回上下文(包含快照)
return { previousTodos };
},
// 请求失败:回滚
onError: (err, updatedTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
toast.error('更新失败,已恢复');
},
// 请求完成(成功或失败):确保数据最终一致
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});五、Infinite Query(无限滚动)
jsx
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) =>
fetch(`/api/posts?cursor=${pageParam}&limit=20`).then(r => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// getPreviousPageParam 用于双向滚动
});
// data.pages 是所有页面数据的数组
const allPosts = data?.pages.flatMap(page => page.items) ?? [];
return (
<div>
{isLoading && <Skeleton count={5} />}
{allPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '加载中...'
: hasNextPage ? '加载更多'
: '没有更多了'}
</button>
</div>
);
}
// 结合 Intersection Observer 实现自动加载
function useInfiniteScroll(ref, { hasNextPage, fetchNextPage, isFetchingNextPage }) {
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, hasNextPage, fetchNextPage, isFetchingNextPage]);
}六、SSR 集成
Next.js App Router(推荐方式)
jsx
// app/layout.tsx
import { QueryProvider } from './providers';
export default function RootLayout({ children }) {
return <html><body><QueryProvider>{children}</QueryProvider></body></html>;
}
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 },
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// app/users/page.tsx(Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function UsersPage() {
const queryClient = new QueryClient();
// 服务端预取
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: () => fetch('https://api.example.com/users').then(r => r.json()),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList /> {/* Client Component,直接使用预取的缓存 */}
</HydrationBoundary>
);
}
// components/UserList.tsx
'use client';
function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
// 服务端已预取,客户端直接使用缓存,不会重新请求
});
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}七、实用模式
依赖查询
jsx
// 先获取 user,再根据 user.teamId 获取 team
function UserTeam({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: team } = useQuery({
queryKey: ['team', user?.teamId],
queryFn: () => fetchTeam(user.teamId),
enabled: !!user?.teamId, // user 加载完成且有 teamId 时才查询
});
}并行查询
jsx
import { useQueries } from '@tanstack/react-query';
function MultiUserProfile({ userIds }) {
const results = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})),
});
const isLoading = results.some(r => r.isLoading);
const users = results.map(r => r.data).filter(Boolean);
}轮询
jsx
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30 * 1000, // 每 30 秒轮询
refetchIntervalInBackground: false, // 页面不可见时停止
});自定义 Hook 封装
jsx
// hooks/useUser.ts
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000,
select: (data) => ({
...data,
displayName: `${data.firstName} ${data.lastName}`,
}),
});
}
// hooks/useUpdateUser.ts
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
queryClient.setQueryData(['user', variables.id], data);
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}八、TanStack Query vs SWR
| 维度 | TanStack Query | SWR |
|---|---|---|
| 功能完整度 | 非常完整 | 轻量精简 |
| Mutation | 内置 useMutation | 需手动实现 |
| 乐观更新 | 内置 onMutate/onError | 手动管理 |
| Infinite Query | 内置 | 内置(useSWRInfinite) |
| DevTools | 官方 DevTools | 社区方案 |
| 离线支持 | 内置 | 需插件 |
| SSR | 完善(dehydrate/hydrate) | 支持 |
| 包体积 | ~13KB | ~4KB |
| 适合 | 复杂应用 | 简单场景 |
面试高频题
Q: staleTime 和 gcTime 的区别?
- staleTime:数据被认为"新鲜"的持续时间。新鲜数据不会触发后台重新获取
- gcTime:查询无活跃观察者(组件卸载)后,缓存在内存中保留的时间。超时后删除
- 类比:staleTime 是"保质期",gcTime 是"超市下架时间"
Q: 为什么 TanStack Query 比 useEffect + useState 好?
- 自动缓存:相同 key 的请求自动去重和复用
- 后台刷新:数据过期后自动在后台更新
- 竞态处理:自动取消过时的请求(userId 快速切换时)
- 重试:请求失败自动重试
- DevTools:可视化查看所有查询的状态和缓存
- 减少样板:不需要手动管理 loading/error/data
Q: queryKey 的设计原则?
- 数组格式:
['entity', id, { filters }] - 层级结构:便于批量失效(
invalidateQueries(['posts'])失效所有 posts 相关查询) - 包含所有依赖:queryFn 中使用的参数必须出现在 queryKey 中
- 确定性:对象的属性顺序不影响(React Query 会深度比较)