关于 tanstack-query
tanstack-query 是用于在 React 应用中使用 TanStack Query v5 的技能,涵盖配置、数据获取、缓存、变更和调试,并说明如何避免迁移常见问题。
主要功能
- 01配置 QueryClient 和 Provider
- 02构建查询与变更
- 03管理缓存和失效
- 04使用乐观更新
- 05通过 React Query DevTools 调试
- 06规避 v5 常见迁移问题
适用场景
- 01为 React 应用添加服务端状态获取
- 02实现带缓存更新的变更流程
- 03在开发中调试查询
- 04将代码迁移至 TanStack Query v5 用法
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jackspace/ claudeskillz --skill tanstack-query安装后在 Claude Code 中输入/tanstack-query确认已加载。
附带文件README.mdSKILL.json
详细介绍
TanStack Query(React Query)v5
状态:已可用于生产环境 ✅ 最后更新:2025-10-22 依赖项:React 18.0+、TypeScript 4.7+(推荐) 最新版本:@tanstack/[email protected]、@tanstack/[email protected]
快速开始(5 分钟)
1. 安装依赖项
npm install @tanstack/react-query@latest
npm install -D @tanstack/react-query-devtools@latest
为何重要:
- TanStack Query v5 要求 React 18+(使用 useSyncExternalStore)
- DevTools 对调试查询和变更至关重要
- v5 有相对于 v4 的破坏性变更——请使用最新版本以获取所有修复
2. 设置 QueryClient Provider
// src/main.tsx or src/index.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60, // 1 hour (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)
重要:
- 使用
QueryClientProvider包裹整个应用 - 配置
staleTime,避免过度重新获取(默认值为 0) - 使用
gcTime(而非cacheTime——v5 中已更名) - DevTools 应放在 Provider 内部
3. 创建第一个查询
// src/hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query'
type Todo = {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error('Failed to fetch todos')
}
return response.json()
}
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
// Usage in component:
function TodoList() {
const { data, isPending, isError, error } = useTodos()
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error: {error.message}</div>
return (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}
重要:
- v5 要求使用对象语法:
useQuery({ queryKey, queryFn }) - 使用
isPending(而非isLoading——现在它表示“待处理且正在获取”) - 始终在 queryFn 中抛出错误,以便正确处理错误
- QueryKey 应为数组,以确保缓存键一致
4. 创建第一个变更
// src/hooks/useAddTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
type NewTodo = {
title: string
}
async function addTodo(newTodo: NewTodo) {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
}
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate and refetch todos
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
// Usage in component:
function AddTodoForm() {
const { mutate, isPending } = useAddTodo()
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
mutate({ title: formData.get('title') as string })
}
return (
<form onSubmit={handleSubmit}>
<input name="title" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add Todo'}
</button>
</form>
)
}
为何有效:
- 变更使用回调(
onSuccess、onError、onSettled),查询则不使用 invalidateQueries会触发后台重新获取- 默认情况下,变更不会缓存(这是正确行为)
7 步设置流程
步骤 1:安装依赖项
# Core library (required)
npm install @tanstack/react-query
# DevTools (highly recommended for development)
npm install -D @tanstack/react-query-devtools
# Optional: ESLint plugin for best practices
npm install -D @tanstack/eslint-plugin-query
软件包的作用:
@tanstack/react-query- 核心 React hooks 和 QueryClient@tanstack/react-query-devtools- 可视化调试工具(仅开发环境使用,可通过 tree-shaking 移除)@tanstack/eslint-plugin-query- 检查常见错误
版本要求:
- React 18.0 或更高版本(使用
useSyncExternalStore) - TypeScript 4.7+ 可获得最佳类型推断效果(可选,但推荐)
步骤 2:配置 QueryClient
// src/lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// How long data is considered fresh (won't refetch during this time)
staleTime: 1000 * 60 * 5, // 5 minutes
// How long inactive data stays in cache before garbage collection
gcTime: 1000 * 60 * 60, // 1 hour (v5: renamed from cacheTime)
// Retry failed requests (0 on server, 3 on client by default)
retry: (failureCount, error) => {
if (error instanceof Response && error.status === 404) return false
return failureCount < 3
},
// Refetch on window focus (can be annoying during dev)
refetchOnWindowFocus: false,
// Refetch on network reconnect
refetchOnReconnect: true,
// Refetch on component mount if data is stale
refetchOnMount: true,
},
mutations: {
// Retry mutations on failure (usually don't want this)
retry: 0,
},
},
})
关键配置决策:
staleTime 与 gcTime:
staleTime:数据被视为“过时”并可能重新获取前的时长0(默认值):数据立即过时,在挂载或窗口获得焦点时重新获取1000 * 60 * 5:数据在 5 分钟内保持新鲜,此期间不会重新获取Infinity:数据永不过时,仅手动使其失效
gcTime:未使用的数据在缓存中保留的时长1000 * 60 * 5(默认值):5 分钟Infinity:永不进行垃圾回收(有内存泄漏风险)
重新获取时机:
refetchOnWindowFocus: true- 适用于频繁变化的数据(如股票价格)refetchOnWindowFocus: false- 适用于稳定数据或开发期间refetchOnMount: true- 确保组件挂载时获取最新数据refetchOnReconnect: true- 网络重新连接后重新获取
步骤 3:使用 Provider 包裹应用
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from './lib/query-client'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
/>
</QueryClientProvider>
</StrictMode>
)
Provider 的放置位置:
- 必须包裹所有使用 TanStack Query hooks 的组件
- DevTools 必须放在 Provider 内部
- 整个应用只使用一个 QueryClient 实例
DevTools 配置:
initialIsOpen={false}- 默认折叠buttonPosition="bottom-right"- 切换按钮的显示位置- 生产构建时会自动移除(通过 tree-shaking)
步骤 4:创建自定义查询 hooks
模式:可复用的查询 hooks
// src/api/todos.ts - API functions
export type Todo = {
id: number
title: string
completed: boolean
}
export async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('/api/todos')
if (!response.ok) {
throw new Error(`Failed to fetch todos: ${response.statusText}`)
}
return response.json()
}
export async function fetchTodoById(id: number): Promise<Todo> {
const response = await fetch(`/api/todos/${id}`)
if (!response.ok) {
throw new Error(`Failed to fetch todo ${id}: ${response.statusText}`)
}
return response.json()
}
// src/hooks/useTodos.ts - Query hooks
import { useQuery, queryOptions } from '@tanstack/react-query'
import { fetchTodos, fetchTodoById } from '../api/todos'
// Query options factory (v5 pattern for reusability)
export const todosQueryOptions = queryOptions({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1 minute
})
export function useTodos() {
return useQuery(todosQueryOptions)
}
export function useTodo(id: number) {
return useQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodoById(id),
enabled: !!id, // Only fetch if id is truthy
})
}
为何使用 queryOptions 工厂:
- 类型推断效果完善
- 可在
useQuery、useSuspenseQuery、prefetchQuery中重复使用 - 在所有地方保持 queryKey 和 queryFn 一致
- 更易于测试和维护
查询键结构:
['todos']- 所有 todos 的列表['todos', id]- 单个 todo 的详情['todos', 'filters', { status: 'completed' }]- 筛选后的列表- 更具体的键是其子集(使
['todos']失效会使所有相关查询失效)
步骤 5:通过乐观更新实现变更
// src/hooks/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Todo } from '../api/todos'
type AddTodoInput = {
title: string
}
type UpdateTodoInput = {
id: number
completed: boolean
}
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (newTodo: AddTodoInput) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
})
if (!response.ok) throw new Error('Failed to add todo')
return response.json()
},
// Optimistic update
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos'])
// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
{ id: Date.now(), ...newTodo, completed: false },
])
// Return context with snapshot
return { previousTodos }
},
// Rollback on error
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos)
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
export function useUpdateTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, completed }: UpdateTodoInput) => {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed }),
})
if (!response.ok) throw new Error('Failed to update todo')
return response.json()
},
onSuccess: (updatedTodo) => {
// Update the specific todo in cache
queryClient.setQueryData<Todo>(['todos', updatedTodo.id], updatedTodo)
// Invalidate list to refetch
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
}
export function useDeleteTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`/api/todos/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete todo')
},
onSuccess: (_, deletedId) => {
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter(todo => todo.id !== deletedId)
)
},
})
}
乐观更新模式:
onMutate:取消查询,保存旧数据快照,并乐观地更新缓存onError:如果变更失败,则回滚到快照onSettled:重新获取数据,确保缓存与服务端一致
适用场景:
- 立即反馈 UI,改善用户体验
- 低风险变更(待办事项切换、点赞按钮)
- 避免用于关键数据(付款、账户设置)
步骤 6:设置 DevTools
// Already set up in main.tsx, but here are advanced options:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
position="bottom"
// Custom toggle button
toggleButtonProps={{
style: { marginBottom: '4rem' },
}}
// Custom panel styles
panelProps={{
style: { height: '400px' },
}}
// Only show in dev (already tree-shaken in production)
// But can add explicit check:
// {import.meta.env.DEV && <ReactQueryDevtools />}
/>
DevTools 功能:
- 查看所有查询及其状态
- 查看查询缓存内容
- 手动重新获取查询
- 查看正在执行的变更
- 检查查询依赖关系
- 导出状态以进行调试
步骤 7:配置错误边界
// src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react'
import { QueryErrorResetBoundary, useQueryErrorResetBoundary } from '@tanstack/react-query'
type Props = { children: ReactNode }
type State = { hasError: boolean }
class ErrorBoundaryClass extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Wrapper with TanStack Query error reset
export function ErrorBoundary({ children }: Props) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundaryClass onReset={reset}>
{children}
</ErrorBoundaryClass>
)}
</QueryErrorResetBoundary>
)
}
// Usage with throwOnError option:
function useTodosWithErrorBoundary() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: true, // Throw errors to error boundary
})
}
// Or conditional:
function useTodosConditionalError() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
throwOnError: (error, query) => {
// Only throw server errors, handle network errors locally
return error instanceof Response && error.status >= 500
},
})
}
错误处理策略:
- 局部处理:使用查询返回的
isError和error - 全局处理:通过
throwOnError使用错误边界 - 混合处理:使用条件式
throwOnError函数 - 集中处理:使用
QueryCache全局错误处理器
关键规则
始终执行
✅ 所有 Hook 都使用对象语法
// v5 ONLY supports this:
useQuery({ queryKey, queryFn, ...options })
useMutation({ mutationFn, ...options })
✅ 使用数组作为查询键
queryKey: ['todos'] // List
queryKey: ['todos', id] // Detail
queryKey: ['todos', { filter }] // Filtered
✅ 适当配置 staleTime
staleTime: 1000 * 60 * 5 // 5 min - prevents excessive refetches
✅ 初始加载状态使用 isPending
if (isPending) return <Loading />
// isPending = no data yet AND fetching
✅ 在 queryFn 中抛出错误
if (!response.ok) throw new Error('Failed')
✅ 变更后使查询失效
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
✅ 使用 queryOptions 工厂实现可复用模式
const opts = queryOptions({ queryKey, queryFn })
useQuery(opts)
useSuspenseQuery(opts)
prefetchQuery(opts)
✅ 使用 gcTime(而非 cacheTime)
gcTime: 1000 * 60 * 60 // 1 hour
绝不执行
❌ 绝不使用 v4 数组/函数语法
// v4 (removed in v5):
useQuery(['todos'], fetchTodos, options) // ❌
// v5 (correct):
useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // ✅
❌ 绝不使用查询回调(查询中的 onSuccess、onError、onSettled)
// v5 removed these from queries:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {}, // ❌ Removed in v5
})
// Use useEffect instead:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
// Do something
}
}, [data])
// Or use mutation callbacks (still supported):
useMutation({
mutationFn: addTodo,
onSuccess: () => {}, // ✅ Still works for mutations
})
❌ 绝不使用已弃用的选项
// Deprecated in v5:
cacheTime: 1000 // ❌ Use gcTime instead
isLoading: true // ❌ Meaning changed, use isPending
keepPreviousData: true // ❌ Use placeholderData instead
onSuccess: () => {} // ❌ Removed from queries
useErrorBoundary: true // ❌ Use throwOnError instead
❌ 绝不认为 isLoading 表示“尚无数据”
// v5 changed this:
isLoading = isPending && isFetching // ❌ Now means "pending AND fetching"
isPending = no data yet // ✅ Use this for initial load
❌ 绝不在无限查询中遗漏 initialPageParam
// v5 requires this:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required in v5
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
❌ 绝不将 enabled 与 useSuspenseQuery 配合使用
// Not allowed:
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not available with suspense
})
// Use conditional rendering instead:
{id && <TodoComponent id={id} />}
预防已知问题
此技能可预防 v5 迁移中的 8 个已记录问题以及常见错误:
问题 #1:必须使用对象语法
错误:useQuery is not a function 或类型错误
来源:v5 迁移指南
发生原因:v5 移除了所有函数重载,只支持对象语法
预防方法:始终使用 useQuery({ queryKey, queryFn, ...options })
之前(v4):
useQuery(['todos'], fetchTodos, { staleTime: 5000 })
之后(v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000
})
问题 #2:已移除查询回调
错误:回调不执行,出现 TypeScript 错误
来源:v5 重大变更
发生原因:查询不再支持 onSuccess、onError、onSettled(变更中仍可使用)
预防方法:副作用使用 useEffect,或将逻辑移至变更回调
之前(v4):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onSuccess: (data) => {
console.log('Todos loaded:', data)
},
})
之后(v5):
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
console.log('Todos loaded:', data)
}
}, [data])
问题 #3:状态从 Loading 改为 Pending
错误:UI 显示错误的加载状态
来源:v5 迁移:isLoading 更名
发生原因:status: 'loading' 更名为 status: 'pending',isLoading 的含义也已改变
预防方法:初始加载使用 isPending,“待处理且正在获取”使用 isLoading
之前(v4):
const { data, isLoading } = useQuery(...)
if (isLoading) return <div>Loading...</div>
之后(v5):
const { data, isPending, isLoading } = useQuery(...)
if (isPending) return <div>Loading...</div>
// isLoading = isPending && isFetching (fetching for first time)
问题 #4:cacheTime 改为 gcTime
错误:cacheTime is not a valid option
来源:v5 迁移:gcTime
发生原因:更名为更准确地表示“垃圾回收时间”
预防方法:使用 gcTime,而非 cacheTime
之前(v4):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
cacheTime: 1000 * 60 * 60,
})
之后(v5):
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
gcTime: 1000 * 60 * 60,
})
问题 #5:useSuspenseQuery + enabled
错误:类型错误,enabled 选项不可用
来源:GitHub 讨论 #6206
原因:Suspense 保证数据可用,因此无法有条件地禁用查询
预防方法:使用条件渲染,而不是 enabled 选项
之前(v4/不正确):
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not allowed
})
之后(v5/正确):
// Conditional rendering:
{id ? (
<TodoComponent id={id} />
) : (
<div>No ID selected</div>
)}
// Inside TodoComponent:
function TodoComponent({ id }: { id: number }) {
const { data } = useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
// No enabled option needed
})
return <div>{data.title}</div>
}
问题 #6:必须提供 initialPageParam
错误:initialPageParam is required 类型错误
来源:v5 迁移:无限查询
原因:v4 将 undefined 作为第一个 pageParam 传入,v5 则要求提供明确的值
预防方法:始终为无限查询指定 initialPageParam
之前(v4):
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
之后(v5):
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // ✅ Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
问题 #7:已移除 keepPreviousData
错误:keepPreviousData is not a valid option
来源:v5 迁移:placeholderData
原因:已替换为更灵活的 placeholderData 函数
预防方法:使用 placeholderData: keepPreviousData 辅助函数
之前(v4):
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
keepPreviousData: true,
})
之后(v5):
import { keepPreviousData } from '@tanstack/react-query'
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
})
问题 #8:TypeScript 错误类型默认值
错误:错误处理时出现类型错误
来源:v5 迁移:错误类型
原因:v4 使用 unknown,v5 默认使用 Error 类型
预防方法:如果抛出非 Error 类型,请显式指定错误类型
之前(v4 - 错误类型为 unknown):
const { error } = useQuery({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw 'custom error string'
return data
},
})
// error: unknown
之后(v5 - 指定自定义错误类型):
const { error } = useQuery<DataType, string>({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw 'custom error string'
return data
},
})
// error: string | null
// Or better: always throw Error objects
const { error } = useQuery({
queryKey: ['data'],
queryFn: async () => {
if (Math.random() > 0.5) throw new Error('custom error')
return data
},
})
// error: Error | null (default)
配置文件参考
package.json(完整示例)
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.90.5"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.90.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.1"
}
}
选择这些版本的原因:
- React 18.3.1 - 使用 useSyncExternalStore 的必需版本
- TanStack Query 5.90.5 - 包含所有 v5 修复的最新稳定版本
- DevTools 5.90.2 - 与 query 版本匹配
- TypeScript 5.6.3 - 对 query 类型提供最佳类型推断
tsconfig.json(TypeScript 配置)
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* TanStack Query specific */
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src"]
}
.eslintrc.cjs(含 TanStack Query 插件的 ESLint 配置)
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'plugin:@tanstack/eslint-plugin-query/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh', '@tanstack/query'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
ESLint 插件可检查:
- 是否将 query 键作为引用,而不是内联编写
- 是否缺少 queryFn
- 是否在 v5 中使用 v4 模式
- useEffect 中的依赖项是否有误
常见模式
模式 1:依赖查询
// Fetch user, then fetch user's posts
function UserPosts({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only fetch posts after user is loaded
})
if (!user) return <div>Loading user...</div>
if (!posts) return <div>Loading posts...</div>
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
适用场景:查询 B 依赖查询 A 返回的数据
模式 2:使用 useQueries 并行查询
// Fetch multiple todos in parallel
function TodoDetails({ ids }: { ids: number[] }) {
const results = useQueries({
queries: ids.map(id => ({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
})),
})
const isLoading = results.some(result => result.isPending)
const isError = results.some(result => result.isError)
if (isLoading) return <div>Loading...</div>
if (isError) return <div>Error loading todos</div>
return (
<ul>
{results.map((result, i) => (
<li key={ids[i]}>{result.data?.title}</li>
))}
</ul>
)
}
适用场景:并行获取多个互不依赖的查询
模式 3:预取
import { useQueryClient } from '@tanstack/react-query'
import { todosQueryOptions } from './hooks/useTodos'
function TodoListWithPrefetch() {
const queryClient = useQueryClient()
const { data: todos } = useTodos()
const prefetchTodo = (id: number) => {
queryClient.prefetchQuery({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 1000 * 60 * 5, // 5 minutes
})
}
return (
<ul>
{todos?.map(todo => (
<li
key={todo.id}
onMouseEnter={() => prefetchTodo(todo.id)}
>
<Link to={`/todos/${todo.id}`}>{todo.title}</Link>
</li>
))}
</ul>
)
}
适用场景:在用户导航之前预加载数据(悬停时、挂载时)
模式 4:无限滚动
import { useInfiniteQuery } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
type Page = {
data: Todo[]
nextCursor: number | null
}
async function fetchTodosPage({ pageParam }: { pageParam: number }): Promise<Page> {
const response = await fetch(`/api/todos?cursor=${pageParam}&limit=20`)
return response.json()
}
function InfiniteTodoList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['todos', 'infinite'],
queryFn: fetchTodosPage,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current)
}
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage])
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.data.map(todo => (
<div key={todo.id}>{todo.title}</div>
))}
</div>
))}
<div ref={loadMoreRef}>
{isFetchingNextPage && <div>Loading more...</div>}
</div>
</div>
)
}
适用场景:实现无限滚动的分页列表
模式 5:取消查询
function SearchTodos() {
const [search, setSearch] = useState('')
const { data } = useQuery({
queryKey: ['todos', 'search', search],
queryFn: async ({ signal }) => {
const response = await fetch(`/api/todos?q=${search}`, { signal })
return response.json()
},
enabled: search.length > 2, // Only search if 3+ characters
})
return (
<div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search todos..."
/>
{data && (
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)}
</div>
)
}
工作原理:
- queryKey 发生变化时,之前的查询会自动取消
- 将
signal传递给 fetch,以便正确清理 - 浏览器会中止尚未完成的 fetch 请求
使用捆绑资源
模板(templates/)
完整且可直接复制的代码示例:
package.json- 包含精确版本的依赖项query-client-config.ts- 遵循最佳实践的 QueryClient 配置provider-setup.tsx- 使用 QueryClientProvider 包装应用use-query-basic.tsx- 基本 useQuery hook 模式use-mutation-basic.tsx- 基本 useMutation hookuse-mutation-optimistic.tsx- 乐观更新模式use-infinite-query.tsx- 无限滚动模式custom-hooks-pattern.tsx- 使用 queryOptions 的可复用查询 hookerror-boundary.tsx- 带查询重置功能的错误边界devtools-setup.tsx- DevTools 配置
使用示例:
# Copy query client config
cp ~/.claude/skills/tanstack-query/templates/query-client-config.ts src/lib/
# Copy provider setup
cp ~/.claude/skills/tanstack-query/templates/provider-setup.tsx src/main.tsx
参考资料(references/)
按需加载的深入文档:
v4-to-v5-migration.md- 完整的 v4 → v5 迁移指南best-practices.md- 请求瀑布、缓存策略、性能common-patterns.md- 可复用查询、乐观更新、无限滚动typescript-patterns.md- 类型安全、泛型、类型推断testing.md- 使用 MSW、React Testing Library 进行测试top-errors.md- 所有 8+ 个错误及解决方案
Claude 应何时加载这些文档:
v4-to-v5-migration.md- 迁移现有 React Query v4 项目时best-practices.md- 优化性能或避免请求瀑布时common-patterns.md- 实现特定功能(如无限滚动等)时typescript-patterns.md- 处理 TypeScript 错误或类型推断时testing.md- 为使用 TanStack Query 的组件编写测试时top-errors.md- 遇到主 SKILL.md 未涵盖的错误时
高级主题
使用 select 转换数据
// Only subscribe to specific slice of data
function TodoCount() {
const { data: count } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length, // Only re-render when count changes
})
return <div>Total todos: {count}</div>
}
// Transform data shape
function CompletedTodoTitles() {
const { data: titles } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) =>
data
.filter(todo => todo.completed)
.map(todo => todo.title),
})
return (
<ul>
{titles?.map((title, i) => (
<li key={i}>{title}</li>
))}
</ul>
)
}
优势:
- 仅当所选数据发生变化时,组件才会重新渲染
- 减少内存使用量(组件状态中存储的数据更少)
- 保持查询缓存不变(其他组件仍可获取完整数据)
请求瀑布(反模式)
// ❌ BAD: Sequential waterfalls
function BadUserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user!.id),
enabled: !!user,
})
const { data: comments } = useQuery({
queryKey: ['comments', posts?.[0]?.id],
queryFn: () => fetchComments(posts![0].id),
enabled: !!posts && posts.length > 0,
})
// Each query waits for previous one = slow!
}
// ✅ GOOD: Fetch in parallel when possible
function GoodUserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
})
// Fetch posts AND comments in parallel
const { data: posts } = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId), // Don't wait for user
})
const { data: comments } = useQuery({
queryKey: ['comments', userId],
queryFn: () => fetchUserComments(userId), // Don't wait for posts
})
// All 3 queries run in parallel = fast!
}
服务端状态与客户端状态
// ❌ Don't use TanStack Query for client-only state
const { data: isModalOpen, setData: setIsModalOpen } = useMutation(...)
// ✅ Use useState for client state
const [isModalOpen, setIsModalOpen] = useState(false)
// ✅ Use TanStack Query for server state
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
经验法则:
- 服务端状态:使用 TanStack Query(来自 API 的数据)
- 客户端状态:使用 useState/useReducer(本地 UI 状态)
- 全局客户端状态:使用 Zustand/Context(主题、身份验证令牌)
依赖项
必需:
@tanstack/[email protected]- 核心库[email protected]+- 使用 useSyncExternalStore hook[email protected]+- React DOM 渲染器
推荐:
@tanstack/[email protected]- 可视化调试器(仅限开发环境)@tanstack/[email protected]- 遵循最佳实践的 ESLint 规则[email protected]+- 用于类型安全和推断
可选:
@tanstack/query-sync-storage-persister- 将缓存持久化到 localStorage@tanstack/query-async-storage-persister- 持久化到 AsyncStorage(React Native)
官方文档
- TanStack Query 文档:https://tanstack.com/query/latest
- React 集成:https://tanstack.com/query/latest/docs/framework/react/overview
- v5 迁移指南:https://tanstack.com/query/latest/docs/framework/react/guides/migrating-to-v5
- API 参考:https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
- Context7 库 ID:
/websites/tanstack_query - GitHub 仓库:https://github.com/TanStack/query
- Discord 社区:https://tlinz.com/discord
软件包版本(已于 2025-10-22 验证)
{
"dependencies": {
"@tanstack/react-query": "^5.90.5"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/eslint-plugin-query": "^5.90.2"
}
}
验证方式:
npm view @tanstack/react-query version→ 5.90.5npm view @tanstack/react-query-devtools version→ 5.90.2- 上次检查:2025-10-22
生产环境示例
此技能基于以下生产环境模式:
- 构建时间:约 6 小时的研究与开发
- 避免的错误:8 个(所有已记录的 v5 迁移问题)
- 令牌效率:相比手动设置节省约 65%
- 验证:✅ 已使用 TypeScript strict mode 测试所有模式
故障排除
问题:"useQuery is not a function" 或类型错误
解决方案:确保使用 v5 对象语法:
// ✅ Correct:
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
// ❌ Wrong (v4 syntax):
useQuery(['todos'], fetchTodos)
问题:查询中的回调(onSuccess、onError)不起作用
解决方案:这些回调已在 v5 中移除。请使用 useEffect 或将其移至 mutations:
// ✅ For queries:
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
useEffect(() => {
if (data) {
// Handle success
}
}, [data])
// ✅ For mutations (still work):
useMutation({
mutationFn: addTodo,
onSuccess: () => { /* ... */ },
})
问题:初次加载期间 isLoading 始终为 false
解决方案:改用 isPending:
const { isPending, isLoading, isFetching } = useQuery(...)
// isPending = no data yet
// isLoading = isPending && isFetching
// isFetching = any fetch in progress
问题:无法识别 cacheTime 选项
解决方案:在 v5 中已重命名为 gcTime:
gcTime: 1000 * 60 * 60 // 1 hour
问题:使用 useSuspenseQuery 并设置 enabled 选项时出现类型错误
解决方案:suspense 不支持 enabled。请使用条件渲染:
{id && <TodoComponent id={id} />}
问题:mutation 后数据未重新获取
解决方案:在 onSuccess 中使查询失效:
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
问题:无限查询需要 initialPageParam
解决方案:在 v5 中始终提供 initialPageParam:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
问题:keepPreviousData 无效
解决方案:已替换为 placeholderData:
import { keepPreviousData } from '@tanstack/react-query'
useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
})
完整设置清单
使用此清单验证设置:
- 已安装 @tanstack/[email protected]+
- 已安装 @tanstack/react-query-devtools(开发依赖)
- 已创建并配置默认值的 QueryClient
- 已使用 QueryClientProvider 包装应用
- 已添加 ReactQueryDevtools 组件
- 已使用对象语法创建第一个查询
- 已测试 isPending 和错误状态
- 已创建带有 onSuccess 处理程序的第一个变更
- 已设置在变更后使查询失效
- 已适当配置 staleTime 和 gcTime
- 始终使用数组形式的 queryKey
- 在 queryFn 中抛出错误
- 未使用 v4 语法(函数重载)
- 查询中未使用回调(查询上的 onSuccess、onError)
- 初始加载时使用 isPending(而非 isLoading)
- 开发环境中的 DevTools 可正常使用
- TypeScript 类型正常工作
- 生产构建成功
有疑问?遇到问题?
- 查看
references/top-errors.md,了解完整的问题解决方案 - 确认设置流程中的所有步骤均已完成
- 查看官方文档:https://tanstack.com/query/latest
- 确保使用 v5 语法(对象语法、gcTime、isPending)
- 加入 Discord:https://tlinz.com/discord