React Query (TanStack): Data Fetching Patterns

Master TanStack Query v5 data fetching patterns: queries, mutations, optimistic updates, infinite scroll, prefetching, cache invalidation, and integration with Next.js App Router.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.6k 字数|

属于我们的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 用于客户端交互,其中用户操作会影响服务器数据。

staleTimegcTime 有什么区别?

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 应用程序。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天