属于我们的Performance & Scalability系列
阅读完整指南React Query (TanStack):数据获取模式
React 中的数据获取具有惊人的大表面积:加载状态、错误状态、后台重新获取、突变后缓存失效、乐观更新、无限滚动、预取和重复数据删除。使用 useEffect + useState 手动编写此逻辑会导致竞争条件、内存泄漏和不一致的 UI 状态。 TanStack Query(以前称为 React Query)v5 通过干净、可组合的 API 解决了所有这些问题。
本指南涵盖了用于生产 React 应用程序的 TanStack Query v5 模式 - 从基本查询到复杂的突变流、乐观更新、无限滚动和 Next.js App Router 集成。每个模式都包含 TypeScript 和错误处理。
要点
- TanStack Query 是一个服务器状态库 - 不要将其用于仅客户端的 UI 状态(为此使用 Zustand 或 useState)
- 查询键是缓存地址——包括影响查询结果的所有变量
staleTime控制后台重新获取何时触发;gcTime控制何时对未使用的数据进行垃圾收集- 突变应使通过
onSuccess中的queryClient.invalidateQueries()的相关查询无效- 乐观更新可提高感知性能,但需要
onError中的回滚逻辑- 使用
useInfiniteQuery作为分页列表;切勿组合useQuery+ 手册页状态- 悬停时预取以实现即时导航感觉:
queryClient.prefetchQuery()- 在 Next.js App Router 中,使用
HydrationBoundary将服务器获取的数据脱水到客户端
设置和配置
pnpm add @tanstack/react-query @tanstack/react-query-devtools
// src/providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
// Create QueryClient inside component to avoid shared state between requests (SSR safety)
const [queryClient] = useState(() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute — data is fresh for 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes — keep unused cache for 5 minutes
retry: 2, // Retry failed requests twice
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // Exponential backoff
refetchOnWindowFocus: true, // Refetch when user returns to tab
refetchOnReconnect: true, // Refetch after network reconnect
},
mutations: {
retry: 0, // Do not retry mutations (non-idempotent)
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// src/app/[locale]/layout.tsx
import { QueryProvider } from '@/providers/query-provider';
export default function LocaleLayout({ children }: { children: React.ReactNode }) {
return (
<QueryProvider>
{children}
</QueryProvider>
);
}
查询键 — 基础
查询键是数据的缓存地址。每个影响查询结果的变量都必须在键中。
// src/lib/query-keys.ts
// Centralize query keys to avoid typos and enable easy invalidation
export const queryKeys = {
contacts: {
all: () => ['contacts'] as const,
lists: () => [...queryKeys.contacts.all(), 'list'] as const,
list: (filters: ContactFilters) =>
[...queryKeys.contacts.lists(), filters] as const,
details: () => [...queryKeys.contacts.all(), 'detail'] as const,
detail: (id: string) =>
[...queryKeys.contacts.details(), id] as const,
},
orders: {
all: () => ['orders'] as const,
list: (orgId: string, page: number) => ['orders', orgId, page] as const,
detail: (id: string) => ['orders', id] as const,
},
} as const;
// Usage in queries and invalidations:
// useQuery({ queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }) })
// queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() })
// — invalidates ALL list queries regardless of filter parameters
基本查询
// src/hooks/use-contacts.ts
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
interface ContactFilters {
page: number;
limit: number;
search?: string;
status?: string;
}
interface ContactsResponse {
data: Contact[];
total: number;
page: number;
limit: number;
}
export function useContacts(filters: ContactFilters) {
return useQuery({
queryKey: queryKeys.contacts.list(filters),
queryFn: async () => {
const params = new URLSearchParams({
page: String(filters.page),
limit: String(filters.limit),
...(filters.search && { search: filters.search }),
...(filters.status && { status: filters.status }),
});
return apiFetch<ContactsResponse>(`/contacts?${params}`);
},
placeholderData: (previousData) => previousData, // Keep previous page data while fetching
staleTime: 30_000, // Contacts data: 30 second stale time
});
}
export function useContact(id: string) {
return useQuery({
queryKey: queryKeys.contacts.detail(id),
queryFn: () => apiFetch<Contact>(`/contacts/${id}`),
enabled: !!id, // Only fetch if id is provided
});
}
// Usage in a component
'use client';
export default function ContactsPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const { data, isLoading, isError, error, isFetching } = useContacts({
page,
limit: 20,
search: search || undefined,
});
if (isLoading) return <ContactsSkeleton />;
if (isError) return <ErrorState message={error.message} />;
return (
<div>
{isFetching && <RefetchIndicator />} {/* Shows during background refetch */}
<ContactsTable data={data.data} />
<Pagination
page={page}
total={data.total}
limit={20}
onChange={setPage}
/>
</div>
);
}
缓存失效的突变
// src/hooks/use-contact-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { toast } from '@/components/ui/toast';
export function useCreateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateContactDto) =>
apiFetch<Contact>('/contacts', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: (newContact) => {
// Invalidate all contact list queries — they are now stale
queryClient.invalidateQueries({
queryKey: queryKeys.contacts.lists(),
});
// Seed the detail cache immediately — no need to refetch
queryClient.setQueryData(
queryKeys.contacts.detail(newContact.id),
newContact
);
toast({ title: 'Contact created', variant: 'success' });
},
onError: (error: Error) => {
toast({ title: 'Failed to create contact', description: error.message, variant: 'destructive' });
},
});
}
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiFetch(`/contacts/${id}`, { method: 'DELETE' }),
onSuccess: (_data, deletedId) => {
// Remove from all list queries
queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() });
// Remove detail cache
queryClient.removeQueries({ queryKey: queryKeys.contacts.detail(deletedId) });
toast({ title: 'Contact deleted', variant: 'success' });
},
});
}
乐观更新
乐观更新通过在服务器确认之前更新 UI 使突变感觉即时:
export function useUpdateContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateContactDto }) =>
apiFetch<Contact>(`/contacts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
// Called immediately before mutationFn
onMutate: async ({ id, data }) => {
// Cancel any in-flight refetches for this contact
await queryClient.cancelQueries({ queryKey: queryKeys.contacts.detail(id) });
// Snapshot the previous value for rollback
const previousContact = queryClient.getQueryData<Contact>(
queryKeys.contacts.detail(id)
);
// Optimistically update the UI
queryClient.setQueryData<Contact>(
queryKeys.contacts.detail(id),
(old) => old ? { ...old, ...data } : old
);
return { previousContact }; // Passed to onError context
},
onError: (_error, { id }, context) => {
// Roll back to the snapshot on error
if (context?.previousContact) {
queryClient.setQueryData(
queryKeys.contacts.detail(id),
context.previousContact
);
}
toast({ title: 'Update failed', variant: 'destructive' });
},
onSettled: (_data, _error, { id }) => {
// Always refetch after success or error to sync with server truth
queryClient.invalidateQueries({ queryKey: queryKeys.contacts.detail(id) });
},
});
}
无限滚动/分页
// src/hooks/use-infinite-contacts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
export function useInfiniteContacts(search?: string) {
return useInfiniteQuery({
queryKey: ['contacts', 'infinite', search],
queryFn: ({ pageParam = 1 }) =>
apiFetch<ContactsResponse>(`/contacts?page=${pageParam}&limit=20${search ? `&search=${search}` : ''}`),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1;
const maxPage = Math.ceil(lastPage.total / lastPage.limit);
return nextPage <= maxPage ? nextPage : undefined;
},
staleTime: 30_000,
});
}
// Infinite scroll component
'use client';
import { useInfiniteContacts } from '@/hooks/use-infinite-contacts';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
export function InfiniteContactsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteContacts();
const { ref, inView } = useInView({ threshold: 0.5 });
// Fetch next page when the sentinel element enters the viewport
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <ContactsSkeleton />;
const contacts = data?.pages.flatMap((page) => page.data) ?? [];
return (
<div>
{contacts.map((contact) => (
<ContactCard key={contact.id} contact={contact} />
))}
{/* Sentinel element — triggers next page load when visible */}
<div ref={ref} className="h-4">
{isFetchingNextPage && <LoadingSpinner />}
</div>
</div>
);
}
预取即时导航
// Prefetch on hover — data is ready before the user clicks
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@/i18n/navigation';
import { queryKeys } from '@/lib/query-keys';
import { apiFetch } from '@/lib/api';
export function ContactRow({ contact }: { contact: Contact }) {
const queryClient = useQueryClient();
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.contacts.detail(contact.id),
queryFn: () => apiFetch<Contact>(`/contacts/${contact.id}`),
staleTime: 60_000, // Use cached if less than 1 minute old
});
};
return (
<tr onMouseEnter={handleMouseEnter}>
<td>
<Link href={`/dashboard/contacts/${contact.id}`}>{contact.name}</Link>
</td>
<td>{contact.email}</td>
</tr>
);
}
Next.js 应用程序路由器 SSR + TanStack 查询
// src/app/[locale]/dashboard/contacts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { getServerContacts } from '@/lib/server-api';
import { ContactsClient } from './contacts-client';
export default async function ContactsPage({ params }: Props) {
const { locale } = await params;
const queryClient = new QueryClient();
// Prefetch on the server — data is embedded in the HTML
await queryClient.prefetchQuery({
queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }),
queryFn: () => getServerContacts({ page: 1, limit: 20 }),
});
return (
// HydrationBoundary serializes the server QueryClient state
// into the HTML, which the client QueryClient then picks up
<HydrationBoundary state={dehydrate(queryClient)}>
<ContactsClient />
</HydrationBoundary>
);
}
// src/app/[locale]/dashboard/contacts/contacts-client.tsx ('use client')
'use client';
import { useContacts } from '@/hooks/use-contacts';
export function ContactsClient() {
const { data } = useContacts({ page: 1, limit: 20 });
// data is immediately available from the server-dehydrated state
// No loading state on first render — SSR data is used instantly
return <ContactsTable data={data?.data ?? []} />;
}
常见问题
什么时候应该使用 TanStack 查询、SWR 和普通获取?
当您需要时使用 TanStack Query:突变后缓存失效、乐观更新、无限滚动、预取、后台重新取或请求重复数据删除。如果您需要较小的捆绑包并且仅需要简单的用例,请使用 SWR。在 Next.js 服务器组件中使用普通的 fetch 来使用 React 的本机数据缓存(cache() 函数和 revalidatePath() 模式)。 TanStack Query 用于客户端交互,其中用户操作会影响服务器数据。
staleTime 和 gcTime 有什么区别?
staleTime 是数据被视为新鲜的时间 — 在此期间,不会发生后台重新获取,并且会立即返回缓存的数据。 staleTime 过期后,数据将“过时”,并将在下次使用时在后台重新获取。 gcTime(以前称为 cacheTime)是未使用的(无活动订阅者)数据在垃圾收集之前在内存中保留的时间。根据数据变化的速度设置 staleTime ;根据您想要导航离开和返回的时间来设置 gcTime 以感觉即时。
如何在全局范围内处理身份验证错误 (401)?
在 QueryClient 默认选项中添加全局错误处理程序。在 401 上,重定向到登录并清除查询缓存。在 TanStack 查询 v5 中:defaultOptions: { queries: { throwOnError: (error) => { if (error.status === 401) { router.push('/auth/login'); queryClient.clear(); return false; } return true; } } }。或者,通过调用 logout() 函数在 apiFetch 帮助程序中处理 401。
如何在同级组件之间共享查询状态而不需要进行 prop 钻取?
任何使用相同查询键调用 useQuery 的组件都会共享相同的缓存数据 - TanStack Query 会自动删除重复请求。使用相同过滤器调用 useContacts({ page: 1, limit: 20 }) 的两个 ContactsTable 组件和一个 ContactCount 徽章都将从单个缓存请求中读取。无需道具钻探,无需上下文。
查询键应该包含用户的会话或组织 ID 吗?
是 - 如果数据的范围仅限于用户或组织,请在查询键中包含范围标识符。这可以防止一个用户的数据显示给另一个用户(对于多租户应用程序至关重要)。注销时还要清除整个查询缓存:queryClient.clear() 或 queryClient.removeQueries() — 否则上一个会话中的过时数据可能会短暂闪烁给下一个用户。
后续步骤
TanStack Query 将 React 数据从状态管理问题获取转换为声明性、缓存感知系统。乐观更新、预取和 SSR Hydration 为您的用户提供即时体验——即使在连接速度较慢的情况下也是如此。
ECOSIRE 使用针对所有服务器状态的 TanStack 查询构建 Next.js 管理面板和客户门户,并与针对仅客户端 UI 状态的 Zustand 和针对数据密集型视图的 TanStack Table 相结合。 探索我们的前端工程服务,了解我们如何构建高性能、可立即投入生产的 React 应用程序。
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
Shopify App Bridge 4 教程:在 2026 年构建嵌入式应用程序
使用 App Bridge 4 构建 Shopify 嵌入式管理应用程序:会话令牌、令牌交换、导航、模式、资源选择器和 Polaris React 13 设置。
React 19 服务器组件迁移指南 2026:真实生产模式
经过实战检验的 React 19 服务器组件迁移指南:数据获取、流式传输、Suspense 陷阱、客户端/服务器边界、陷阱和测量的性能胜利。
Next.js 16 应用程序路由器:生产模式和陷阱
生产就绪的 Next.js 16 个 App Router 模式:服务器组件、缓存策略、元数据 API、错误边界和要避免的性能陷阱。
更多来自Performance & Scalability
Odoo 19 HR:技能矩阵、职业规划、绩效周期
Odoo 19 HR 升级:本地技能矩阵、职业道路规划、绩效评估周期、9 框网格、继任计划、HRIS 集成。
Odoo 19 性能基准:PostgreSQL 17 调整数字
真实的 Odoo 19 性能基准:Web 客户端速度、ORM 吞吐量、PG17 调整设置、连接池、工作线程数、扩展阈值。
OpenClaw 大规模成本优化和代币效率
OpenClaw 令牌成本优化:提示缓存、模型路由、响应缓存、批处理 API 和生产代理的每租户成本护栏。
Power BI 增量刷新超过 1000 万行的表
适用于 10M 以上行表的 Power BI 增量刷新手册:分区设计、RangeStart/RangeEnd、刷新策略、查询折叠和 DirectQuery 混合。
Webhook 调试和监控:完整的故障排除指南
通过这份涵盖故障模式、调试工具、重试策略、监控仪表板和安全最佳实践的完整指南掌握 Webhook 调试。
k6 负载测试:在发布之前对您的 API 进行压力测试
掌握 Node.js API 的 k6 负载测试。涵盖虚拟用户启动、阈值、场景、HTTP/2、WebSocket 测试、Grafana 仪表板和 CI 集成模式。