mcpField
T
tanstack-query

tanstack-query

github.com/mindrally开发289736 次安装3 周前更新
代码仓库

关于 tanstack-query

这项技能介绍如何在 TypeScript 应用中使用 TanStack Query v5 管理服务端状态与缓存,侧重类型安全查询、缓存操作、变更处理及避免过时的 v4 API。

主要功能

  1. 01稳定的层级查询键
  2. 02可复用的类型化 queryOptions
  3. 03依赖查询与并行查询
  4. 04变更操作与缓存失效
  5. 05Suspense 和分页查询
  6. 06v5 迁移指南

适用场景

  1. 01在多个组件间组织查询定义
  2. 02获取依赖数据或并行数据
  3. 03写入后保持缓存一致
  4. 04实现分页数据加载

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/mindrally/skills --skill tanstack-query

安装后在 Claude Code 中输入/tanstack-query确认已加载。

详细介绍

TanStack Query 最佳实践

TanStack Query(以前称为 React Query)开箱即用地处理服务端状态缓存、后台更新和过期数据管理。本指南介绍 v5 的模式和 API——v5 引入了多项相较于 v4 的重大变更,网上的旧示例仍未反映这些变化。

核心原则

  • 使用 TanStack Query 管理所有服务端状态和数据获取;它不是通用的客户端状态管理器——请将仅限客户端的状态保存在 useState、context 或状态库中
  • 减少使用 useEffect 和 useState 管理服务端数据;优先使用 TanStack Query 内置的状态管理功能
  • 每个查询都需要稳定、可序列化的查询键,用于唯一描述其所持有的数据
  • 写操作由 mutations 处理,读操作由 queries 处理——不要混淆两者
  • 实施完善的错误处理,并提供用户易于理解的提示
  • 使用 TypeScript 确保查询响应具有完整的类型安全性

需要注意的 v5 重大变更

如果看到或编写了以下任何 v4 模式,请进行更新:

  • 仅支持对象语法:useQuery、useInfiniteQuery 等不再接受位置参数(useQuery(key, fn, options))。始终传入单个 options 对象:useQuery({ queryKey, queryFn, ...options })。
  • isPending 取代 isLoading,用于表示 useMutation 中“尚无数据且正在执行获取”的状态。对于 useQuery,isPending 表示完全没有缓存数据;现在 isLoading 是派生状态(isPending && isFetching),仍可用于经典的“首次加载”加载指示器场景。
  • cacheTime 更名为 gcTime(垃圾回收时间)。
  • 用于定义可复用、类型化查询定义的 queryOptions() 辅助函数,可供组件、加载器和预取调用共享。
  • 基于 Suspense 获取数据时使用 useSuspenseQuery(以及 useSuspenseInfiniteQuery),取代旧的 suspense: true 选项。
  • 分页时使用 placeholderData: keepPreviousData,取代旧的 keepPreviousData: true 布尔值。

项目结构

src/
  api/
    client.ts             # API client configuration
    endpoints/
      users.ts            # User-related API calls
      posts.ts            # Post-related API calls
  queries/
    postKeys.ts            # Query key factory
    postQueryOptions.ts     # queryOptions() definitions
  hooks/
    queries/
      useUsers.ts         # User query hooks
      usePosts.ts         # Post query hooks
    mutations/
      useCreateUser.ts    # User mutation hooks
  providers/
    QueryProvider.tsx     # Query client provider setup
  types/
    api.ts                # API response types

设置和配置

Query Client 配置
// providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes; defaults to 0 (always stale) if unset
      gcTime: 1000 * 60 * 30,   // 30 minutes (formerly cacheTime)
      retry: (failureCount, error: any) => error?.status !== 404 && failureCount < 3,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 1,
    },
  },
});

export function QueryProvider({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

在应用根部只实例化一次 QueryClient——不要在组件内部实例化,否则缓存会在每次渲染时重置。

查询最佳实践

1. 查询键组织

使用一致的分层查询键,以便高效管理缓存:

// Query key factory pattern
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};
2. queryOptions 辅助函数(v5)

使用 queryOptions() 定义一次查询,并在组件、路由加载器和预取调用中复用同一定义——这样查询键、查询函数和选项就能集中在一处,而不必重复编写:

// queries/postQueryOptions.ts
import { queryOptions } from '@tanstack/react-query';
import { postKeys } from './postKeys';
import { fetchPost } from '@/api/endpoints/posts';

export const postQueryOptions = (id: string) =>
  queryOptions({
    queryKey: postKeys.detail(id),
    queryFn: () => fetchPost(id),
    staleTime: 1000 * 60 * 5,
  });

// In a component
const { data } = useQuery(postQueryOptions(postId));

// In a TanStack Router loader — eliminates loading spinners on navigation
export const Route = createFileRoute('/posts/$postId')({
  loader: ({ params, context: { queryClient } }) =>
    queryClient.ensureQueryData(postQueryOptions(params.postId)),
});

始终在组件外定义 queryOptions——不要在每次 useQuery() 调用中内联创建新的对象字面量——这样才能共享和预取该定义。

3. 自定义查询 Hook

当 queryOptions() 工厂函数不会在其他地方复用时,可创建可复用、类型化的查询 Hook:

// hooks/queries/useUser.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { userKeys } from '@/api/queryKeys';
import { getUser, User } from '@/api/endpoints/users';

export function useUser(
  userId: string,
  options?: Omit<UseQueryOptions<User, Error>, 'queryKey' | 'queryFn'>
) {
  return useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => getUser(userId),
    enabled: !!userId,
    ...options,
  });
}
4. 依赖查询

处理依赖其他数据的查询:

function useUserPosts(userId: string) {
  const { data: user } = useUser(userId);

  return useQuery({
    queryKey: ['posts', { userId }],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // Only run when user data is available
  });
}
5. 并行查询

同时获取多个资源:

import { useQueries } from '@tanstack/react-query';

function useMultipleUsers(userIds: string[]) {
  return useQueries({
    queries: userIds.map((id) => ({
      queryKey: userKeys.detail(id),
      queryFn: () => getUser(id),
    })),
  });
}

Mutation 最佳实践

1. 基本 Mutations
const { mutate, mutateAsync, isPending } = useMutation({
  mutationFn: (input: CreatePostInput) => createPost(input),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: postKeys.lists() });
    toast.success('Post created!');
  },
  onError: (error) => {
    toast.error(error.message);
  },
});

// Usage
mutate({ title: 'Hello', body: '...' });

isPending 是 v5 中表示“mutation 正在执行”的名称(v4 中 mutations 也使用 isLoading 表示此状态——该名称已废弃)。

2. 乐观更新

在 mutations 执行期间提供即时反馈:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateUser,
    onMutate: async (newUser) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) });

      // Snapshot previous value
      const previousUser = queryClient.getQueryData(userKeys.detail(newUser.id));

      // Optimistically update
      queryClient.setQueryData(userKeys.detail(newUser.id), newUser);

      return { previousUser };
    },
    onError: (err, newUser, context) => {
      // Rollback on error
      queryClient.setQueryData(
        userKeys.detail(newUser.id),
        context?.previousUser
      );
    },
    onSettled: (data, error, variables) => {
      // Refetch after error or success
      queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
    },
  });
}
3. 缓存失效

在 mutations 后正确地使相关查询失效:

function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: deleteUser,
    onSuccess: () => {
      // Invalidate all user-related queries
      queryClient.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

其他值得了解的缓存操作:

// Remove from cache entirely (not just marked stale)
queryClient.removeQueries({ queryKey: userKeys.detail(id) });

// Directly write to the cache without a refetch
queryClient.setQueryData(userKeys.detail(id), newData);

错误处理

1. 全局错误处理器
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      throwOnError: false,
    },
    mutations: {
      onError: (error) => {
        // Global error handling (e.g., toast notification)
        toast.error(error.message);
      },
    },
  },
});
2. 组件级错误处理
function UserProfile({ userId }: { userId: string }) {
  const { data, error, isPending, isError } = useUser(userId);

  if (isPending) return <Skeleton />;
  if (isError) return <ErrorMessage error={error} />;

  return <UserCard user={data} />;
}
3. 条件重试逻辑

对于重试也不会成功的错误(例如 404),跳过重试:

retry: (failureCount, error) => {
  if (error.status === 404) return false;
  return failureCount < 3;
},
4. Suspense 模式(v5)

使用 useSuspenseQuery 进行基于 Suspense 的数据获取,而不是旧的 suspense: true 选项——它还会收窄返回类型,因为 data 永远不会是 undefined:

import { useSuspenseQuery } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

function UserProfile({ userId }: { userId: string }) {
  // No need to check isPending — Suspense handles the loading state
  const { data } = useSuspenseQuery(userQueryOptions(userId));
  return <UserCard user={data} />;
}

function App() {
  return (
    <ErrorBoundary fallback={<ErrorFallback />}>
      <Suspense fallback={<Loading />}>
        <UserProfile userId="123" />
      </Suspense>
    </ErrorBoundary>
  );
}

如果你希望错误冒泡到最近的 ErrorBoundary,但不切换到 Suspense,可在常规 useQuery 上设置 throwOnError: true。

性能优化

1. 选择并转换数据

只订阅所需的数据:

function useUserName(userId: string) {
  return useUser(userId, {
    select: (user) => user.name,
  });
}
2. 预取

提前预取数据——可以在悬停时或路由过程中进行:

function UserList() {
  const queryClient = useQueryClient();

  const prefetchUser = (userId: string) => {
    queryClient.prefetchQuery(userQueryOptions(userId));
  };

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id} onMouseEnter={() => prefetchUser(user.id)}>
          {user.name}
        </li>
      ))}
    </ul>
  );
}
3. 无限查询

高效处理分页数据:

import { useInfiniteQuery } from '@tanstack/react-query';

function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: postKeys.lists(),
    queryFn: ({ pageParam }) => fetchPosts(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    getPreviousPageParam: (firstPage) => firstPage.prevCursor,
  });
}

// data.pages is an array of page results — flatten for rendering
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];

在分页或筛选查询上使用 placeholderData: keepPreviousData(从 @tanstack/react-query 导入),在加载下一页时继续显示上一页的数据,而不是闪现加载状态:

import { keepPreviousData, useQuery } from '@tanstack/react-query';

useQuery({
  queryKey: postKeys.list({ page }),
  queryFn: () => fetchPosts({ page }),
  placeholderData: keepPreviousData,
});

使用 notifyOnChangeProps 将重新渲染限制为组件实际读取的特定结果属性。

TypeScript 技巧

  • 始终显式标注 queryFn 的返回值类型,或从有类型的 API 函数推断
  • 使用 QueryObserverResult<TData, TError> 标注钩子返回值的类型
  • 对 mutation 使用 UseMutationResult<TData, TError, TVariables>

关键约定

  1. 按功能组织:在特定功能目录中归类查询钩子和 queryOptions 工厂函数
  2. 保持查询键一致:使用工厂函数创建类型安全且组织有序的查询键
  3. 可复用时始终使用 queryOptions:只要某个查询会在多个位置使用(组件、loader、预取),就优先使用 queryOptions(),而不是临时内联选项
  4. 类型安全:为所有 API 响应定义 TypeScript 接口
  5. DevTools:始终在开发环境中加入 React Query DevTools
  6. 避免查询嵌套过深:尽可能扁平化查询结构
  7. 只获取所需数据:使用 API 参数限制响应大小
  8. 处理加载和错误状态:始终提供适当的 UI 反馈

应避免的反模式

  • 不要使用 useEffect 获取数据——改用查询或路由加载器
  • 不要将服务端状态存储在本地状态中(useState)
  • 不要向 useQuery/useInfiniteQuery 传递位置参数——v5 要求使用选项对象形式
  • 不要只检查 mutation 的 isLoading——应使用 isPending
  • 不要忘记处理加载和错误状态
  • 不要创建过于具体的查询键,以免妨碍缓存复用
  • 不要在 mutation 后跳过缓存失效处理
  • 条件查询时不要忽略 enabled 选项
  • 如果会在其他地方复用,不要在组件内联定义 queryOptions/查询配置——应将其放在一起并共享

评论

支持 Markdown