mcpField
T
tanstack-query

tanstack-query

github.com/tanstack-skills开发3610.0k 次安装39 周前更新
代码仓库

关于 tanstack-query

TanStack Query 是用于 React 应用管理服务端状态的库,面向需要获取、缓存和同步远端数据的开发者。它提供查询、变更、分页、无限滚动及乐观更新等机制。

主要功能

  1. 01服务端数据缓存与后台刷新
  2. 02查询和变更状态管理
  3. 03分页与无限滚动查询
  4. 04乐观更新和查询失效
  5. 05SSR、水合与持久化

适用场景

  1. 01缓存并同步应用中的远端数据
  2. 02构建分页或无限滚动列表
  3. 03在提交变更时即时更新界面
  4. 04为 SSR 页面预取并水合数据

安装

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

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

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

详细介绍

概述

TanStack Query(原名 React Query)用于管理服务端状态——即存储在服务器上,需要获取、缓存、同步和更新的数据。它开箱即用地提供自动缓存、后台重新获取、过期后重新验证模式、分页、无限滚动和乐观更新。

软件包: @tanstack/react-query 开发工具: @tanstack/react-query-devtools 当前版本: v5

安装

npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools  # Optional

设置

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60, // 1 minute
      gcTime: 1000 * 60 * 5, // 5 minutes (garbage collection)
      retry: 3,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
  },
})

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

核心概念

查询键

查询键用于唯一标识缓存的数据。它们必须是可序列化的数组:

// Simple key
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

// With variables (dependency array pattern)
useQuery({ queryKey: ['todos', { status, page }], queryFn: fetchTodos })

// Hierarchical keys for invalidation
useQuery({ queryKey: ['todos', todoId], queryFn: () => fetchTodo(todoId) })
useQuery({ queryKey: ['todos', todoId, 'comments'], queryFn: () => fetchComments(todoId) })

// Invalidation matches prefixes:
// queryClient.invalidateQueries({ queryKey: ['todos'] })
// ^ Invalidates ALL queries starting with 'todos'
查询函数
// Query function receives a QueryFunctionContext
useQuery({
  queryKey: ['todos', todoId],
  queryFn: async ({ queryKey, signal, meta }) => {
    const [_key, id] = queryKey
    const response = await fetch(`/api/todos/${id}`, { signal })
    if (!response.ok) throw new Error('Failed to fetch')
    return response.json()
  },
})

// Using the signal for automatic cancellation
useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const response = await fetch('/api/todos', { signal })
    return response.json()
  },
})
queryOptions 辅助函数

创建可复用且类型安全的查询配置:

import { queryOptions } from '@tanstack/react-query'

export const todosQueryOptions = queryOptions({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
})

export const todoQueryOptions = (todoId: string) =>
  queryOptions({
    queryKey: ['todos', todoId],
    queryFn: () => fetchTodo(todoId),
    enabled: !!todoId,
  })

// Usage
const { data } = useQuery(todosQueryOptions)
const { data } = useSuspenseQuery(todoQueryOptions(id))
await queryClient.prefetchQuery(todosQueryOptions)

查询(useQuery)

基本用法
import { useQuery } from '@tanstack/react-query'

function Todos() {
  const {
    data,
    error,
    isLoading,      // First load, no data yet
    isFetching,     // Any fetch in progress (including background)
    isError,
    isSuccess,
    isPending,      // No data yet (same as isLoading in most cases)
    status,         // 'pending' | 'error' | 'success'
    fetchStatus,    // 'fetching' | 'paused' | 'idle'
    refetch,
    isStale,
    isPlaceholderData,
    dataUpdatedAt,
    errorUpdatedAt,
  } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  if (isLoading) return <Spinner />
  if (isError) return <Error message={error.message} />
  return <TodoList todos={data} />
}
查询选项
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,

  // Freshness
  staleTime: 5000,            // ms data stays fresh (default: 0)
  gcTime: 300000,             // ms unused data stays in cache (default: 5 min)

  // Refetching
  refetchInterval: 10000,     // Poll every 10s
  refetchIntervalInBackground: false, // Don't poll when tab hidden
  refetchOnMount: true,       // Refetch on component mount if stale
  refetchOnWindowFocus: true, // Refetch on window focus if stale
  refetchOnReconnect: true,   // Refetch on network reconnect

  // Retry
  retry: 3,                   // Number of retries (or function)
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),

  // Conditional
  enabled: !!userId,          // Only run when truthy

  // Initial/placeholder data
  initialData: () => cachedData,
  initialDataUpdatedAt: Date.now() - 10000,
  placeholderData: (previousData) => previousData, // keepPreviousData pattern
  placeholderData: initialTodos,

  // Transform
  select: (data) => data.filter(todo => !todo.done),

  // Structural sharing (default: true)
  structuralSharing: true,

  // Network mode
  networkMode: 'online', // 'online' | 'always' | 'offlineFirst'

  // Meta (accessible in query function context)
  meta: { purpose: 'user-facing' },
})

变更(useMutation)

基本用法
import { useMutation, useQueryClient } from '@tanstack/react-query'

function AddTodo() {
  const queryClient = useQueryClient()

  const mutation = useMutation({
    mutationFn: (newTodo: { title: string }) => {
      return fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(newTodo),
      }).then(res => res.json())
    },
    // Lifecycle callbacks
    onMutate: async (variables) => {
      // Called before mutationFn
      // Good for optimistic updates
      return { previousTodos } // context for onError
    },
    onSuccess: (data, variables, context) => {
      // Invalidate related queries
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
    onError: (error, variables, context) => {
      // Rollback optimistic updates
      queryClient.setQueryData(['todos'], context.previousTodos)
    },
    onSettled: (data, error, variables, context) => {
      // Always runs (success or error)
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })

  return (
    <button
      onClick={() => mutation.mutate({ title: 'New Todo' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Adding...' : 'Add Todo'}
    </button>
  )
}
变更状态
const {
  mutate,         // Fire-and-forget
  mutateAsync,    // Returns promise
  isPending,      // Mutation in progress
  isError,
  isSuccess,
  isIdle,         // Not yet fired
  data,           // Success response
  error,          // Error object
  reset,          // Reset state to idle
  variables,      // Variables passed to mutate
  status,         // 'idle' | 'pending' | 'error' | 'success'
} = useMutation({ ... })

乐观更新

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // 1. Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos', newTodo.id] })

    // 2. Snapshot previous value
    const previousTodo = queryClient.getQueryData(['todos', newTodo.id])

    // 3. Optimistically update
    queryClient.setQueryData(['todos', newTodo.id], newTodo)

    // 4. Return context for rollback
    return { previousTodo }
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos', newTodo.id], context.previousTodo)
  },
  onSettled: () => {
    // Always refetch to sync with server
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})
列表的乐观更新
onMutate: async (newTodo) => {
  await queryClient.cancelQueries({ queryKey: ['todos'] })
  const previousTodos = queryClient.getQueryData(['todos'])

  queryClient.setQueryData(['todos'], (old) => [...old, newTodo])

  return { previousTodos }
},
onError: (err, newTodo, context) => {
  queryClient.setQueryData(['todos'], context.previousTodos)
},

查询失效

const queryClient = useQueryClient()

// Invalidate all queries
queryClient.invalidateQueries()

// Invalidate by prefix
queryClient.invalidateQueries({ queryKey: ['todos'] })

// Invalidate exact match
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true })

// Invalidate with predicate
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' && query.queryKey[1]?.status === 'done',
})

// Invalidate and refetch immediately
queryClient.refetchQueries({ queryKey: ['todos'] })

// Remove from cache entirely
queryClient.removeQueries({ queryKey: ['todos', 1] })

// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })

无限查询

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

function InfiniteList() {
  const {
    data,
    fetchNextPage,
    fetchPreviousPage,
    hasNextPage,
    hasPreviousPage,
    isFetchingNextPage,
    isFetchingPreviousPage,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/projects?cursor=${pageParam}`)
      return res.json()
    },
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages, lastPageParam) => {
      return lastPage.nextCursor ?? undefined // undefined = no more pages
    },
    getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
      return firstPage.prevCursor ?? undefined
    },
    maxPages: 3, // Keep max 3 pages in cache (for performance)
  })

  return (
    <div>
      {data.pages.map((page) =>
        page.items.map((item) => <Item key={item.id} item={item} />)
      )}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : hasNextPage ? 'Load More' : 'No more'}
      </button>
    </div>
  )
}

并行查询

// Multiple independent queries run in parallel automatically
function Dashboard() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })

  // Both fetch simultaneously
}

// Dynamic parallel queries with useQueries
function UserProjects({ userIds }) {
  const queries = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
    combine: (results) => ({
      data: results.map(r => r.data),
      pending: results.some(r => r.isPending),
    }),
  })
}

依赖查询

// Sequential queries using enabled
function UserPosts({ userId }) {
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchPostsByUser(userId),
    enabled: !!userQuery.data, // Only run when user is loaded
  })
}

分页查询

function PaginatedList() {
  const [page, setPage] = useState(1)

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['todos', page],
    queryFn: () => fetchTodos(page),
    placeholderData: (previousData) => previousData, // Keep showing old data
  })

  return (
    <div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
      {data.items.map(item => <Item key={item.id} item={item} />)}
      <button
        onClick={() => setPage(p => p + 1)}
        disabled={isPlaceholderData || !data.hasMore}
      >
        Next
      </button>
    </div>
  )
}

Suspense 集成

import { useSuspenseQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query'

// Component will suspend until data is loaded
function TodoList() {
  const { data } = useSuspenseQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
  // data is guaranteed to be defined here
  return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul>
}

// Wrap with Suspense boundary
function App() {
  return (
    <ErrorBoundary fallback={<Error />}>
      <Suspense fallback={<Loading />}>
        <TodoList />
      </Suspense>
    </ErrorBoundary>
  )
}

// Multiple suspense queries (fetch in parallel)
function Dashboard() {
  const [{ data: users }, { data: projects }] = useSuspenseQueries({
    queries: [
      { queryKey: ['users'], queryFn: fetchUsers },
      { queryKey: ['projects'], queryFn: fetchProjects },
    ],
  })
}

预取

const queryClient = useQueryClient()

// Prefetch on hover
function TodoLink({ todoId }) {
  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['todo', todoId],
      queryFn: () => fetchTodo(todoId),
      staleTime: 5000, // Only prefetch if data older than 5s
    })
  }

  return (
    <Link to={`/todos/${todoId}`} onMouseEnter={prefetch}>
      Todo {todoId}
    </Link>
  )
}

// Prefetch in route loader (TanStack Router integration)
export const Route = createFileRoute('/todos/$todoId')({
  loader: ({ context: { queryClient }, params: { todoId } }) =>
    queryClient.ensureQueryData(todoQueryOptions(todoId)),
})

// Prefetch infinite queries
queryClient.prefetchInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  pages: 3, // Prefetch first 3 pages
})

SSR 与水合

服务端预取
// Server component or loader
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'

async function getServerSideProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

function Page({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <Todos />
    </HydrationBoundary>
  )
}
流式 SSR(React Server Components)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { makeQueryClient } from './query-client'

export default async function Page() {
  const queryClient = makeQueryClient()

  // Prefetch on server
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TodoList />
    </HydrationBoundary>
  )
}

QueryClient API

const queryClient = useQueryClient()

// Get cached data
queryClient.getQueryData(['todos'])

// Set cached data
queryClient.setQueryData(['todos'], updatedTodos)
queryClient.setQueryData(['todos'], (old) => [...old, newTodo])

// Get query state
queryClient.getQueryState(['todos'])

// Check if fetching
queryClient.isFetching({ queryKey: ['todos'] })
queryClient.isMutating()

// Cancel queries
queryClient.cancelQueries({ queryKey: ['todos'] })

// Invalidate (marks stale, refetches active)
queryClient.invalidateQueries({ queryKey: ['todos'] })

// Refetch (force refetch even if fresh)
queryClient.refetchQueries({ queryKey: ['todos'] })

// Remove from cache
queryClient.removeQueries({ queryKey: ['todos'] })

// Reset to initial state
queryClient.resetQueries({ queryKey: ['todos'] })

// Clear entire cache
queryClient.clear()

// Prefetch
queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: fetchTodos })
queryClient.ensureQueryData({ queryKey: ['todos'], queryFn: fetchTodos })

// Get/set defaults
queryClient.setQueryDefaults(['todos'], { staleTime: 10000 })
queryClient.getQueryDefaults(['todos'])
queryClient.setMutationDefaults(['addTodo'], { mutationFn: addTodo })

测试

import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false, // Don't retry in tests
        gcTime: Infinity, // Prevent garbage collection during tests
      },
    },
  })
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

test('fetches todos', async () => {
  const { result } = renderHook(() => useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  }), { wrapper: createWrapper() })

  await waitFor(() => expect(result.current.isSuccess).toBe(true))
  expect(result.current.data).toEqual(expectedTodos)
})

// Mock with setQueryData for component tests
test('renders todos', () => {
  const queryClient = new QueryClient()
  queryClient.setQueryData(['todos'], mockTodos)

  render(
    <QueryClientProvider client={queryClient}>
      <TodoList />
    </QueryClientProvider>
  )

  expect(screen.getByText('Todo 1')).toBeInTheDocument()
})

TypeScript 模式

为查询函数指定类型
interface Todo {
  id: number
  title: string
  completed: boolean
}

// Type is inferred from queryFn return type
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: async (): Promise<Todo[]> => {
    const res = await fetch('/api/todos')
    return res.json()
  },
})
// data: Todo[] | undefined

// With select
const { data } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: (data): string[] => data.map(t => t.title),
})
// data: string[] | undefined
为错误指定类型
// Default error type is Error
const { error } = useQuery<Todo[], AxiosError>({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

// Or register globally
declare module '@tanstack/react-query' {
  interface Register {
    defaultError: AxiosError
  }
}
查询选项模式(推荐)
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query'

export const todosOptions = queryOptions({
  queryKey: ['todos'] as const,
  queryFn: fetchTodos,
  staleTime: 5000,
})

export const todoOptions = (id: string) =>
  queryOptions({
    queryKey: ['todos', id] as const,
    queryFn: () => fetchTodo(id),
    enabled: !!id,
  })

// Full type inference everywhere
const { data } = useQuery(todosOptions)
const { data } = useSuspenseQuery(todoOptions('123'))
await queryClient.ensureQueryData(todosOptions)
queryClient.invalidateQueries({ queryKey: todosOptions.queryKey })

高级模式

窗口聚焦时重新获取
// Disable globally
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { refetchOnWindowFocus: false },
  },
})

// Custom focus manager
import { focusManager } from '@tanstack/react-query'

// For React Native
focusManager.setEventListener((handleFocus) => {
  const subscription = AppState.addEventListener('change', (state) => {
    handleFocus(state === 'active')
  })
  return () => subscription.remove()
})
网络模式
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  // 'online' (default): only fetch when online
  // 'always': always fetch (useful for local-first)
  // 'offlineFirst': try fetch, use cache if offline
  networkMode: 'offlineFirst',
})
查询取消
useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    // signal is AbortSignal - automatically cancelled on unmount or key change
    const res = await fetch('/api/todos', { signal })
    return res.json()
  },
})

// Manual cancellation
queryClient.cancelQueries({ queryKey: ['todos'] })
持久化
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'

const persister = createSyncStoragePersister({
  storage: window.localStorage,
})

persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24, // 24 hours
})

最佳实践

  1. 使用 queryOptions 辅助函数,创建类型安全且可复用的查询配置
  2. 按层级构建查询键,以便进行细粒度失效处理
  3. 设置合适的 staleTime——0 表示挂载时始终重新获取(默认值);对于变化较少的数据,可调高此值
  4. 使用 placeholderData(而非 initialData),在分页期间保留上一页的数据
  5. 使用 Suspense 边界时,优先选择 useSuspenseQuery,以简化组件代码
  6. 使用 enabled 处理依赖查询,不要有条件地调用 Hook
  7. 变更后始终使查询失效——不要只依赖乐观更新
  8. 在乐观更新前于 onMutate 中取消查询,以防止竞态条件
  9. 在路由加载器中使用 ensureQueryData,而非 prefetchQuery,以便立即访问数据
  10. 在测试中设置 retry: false,避免超时问题 11.如果需要传递查询结果,请勿对其解构(这会破坏响应性)
  11. 使用 select 获取派生数据,而不是在组件中进行转换
  12. 保持查询函数纯粹——它们只应获取数据,不应产生副作用
  13. 在测试中使用 gcTime: Infinity,防止断言期间清理缓存

常见问题

  • 想用 placeholderData 时却使用了 initialData(initialData 会被视为“新鲜”数据)
  • 无限查询未提供 initialPageParam(v5 中必需)
  • 有条件地调用 Hook(违反 React 规则)
  • 乐观更新前未取消查询(会产生竞态条件)
  • 将 staleTime 设置得高于 gcTime(数据会在仍被视为“新鲜”时被垃圾回收)
  • 忘记在测试中使用 QueryClientProvider 包裹组件
  • 在多个测试中使用同一个 QueryClient 实例(导致状态共享)
  • 当顺序很重要时,未在变更回调中等待 invalidateQueries 完成

评论

支持 Markdown