Manage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery.
Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
Une commande directe contourne le prompt de vérification. Examinez la source avant de l'exécuter.
Manage server state in React with TanStack Query v5. Set up queries with useQuery, mutations with useMutation, configure QueryClient caching strategies, implement optimistic updates, and handle infinite scroll with useInfiniteQuery.
Use when: setting up data fetching in React projects, migrating from v4 to v5, or fixing object syntax required errors, query callbacks removed issues, cacheTime renamed to gcTime, isPending vs isLoading confusion, keepPreviousData removed problems.
license
MIT
TanStack Query (React Query) v5
Status: Production Ready ✅
Last Updated: 2025-10-22
Dependencies: React 18.0+, TypeScript 4.7+ (recommended)
Latest Versions: @tanstack/react-query@5.90.5, @tanstack/react-query-devtools@5.90.2
@tanstack/eslint-plugin-query - Catches common mistakes
Version requirements:
React 18.0 or higher (uses useSyncExternalStore)
TypeScript 4.7+ for best type inference (optional but recommended)
Step 2: Configure 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,
},
},
})
Key configuration decisions:
staleTime vs gcTime:
staleTime: How long until data is considered "stale" and might refetch
0 (default): Data is immediately stale, refetches on mount/focus
1000 * 60 * 5: Data fresh for 5 min, no refetch during this time
Infinity: Data never stale, manual invalidation only
gcTime: How long unused data stays in cache
1000 * 60 * 5 (default): 5 minutes
Infinity: Never garbage collect (memory leak risk)
When to refetch:
refetchOnWindowFocus: true - Good for frequently changing data (stock prices)
refetchOnWindowFocus: false - Good for stable data or during development
refetchOnMount: true - Ensures fresh data when component mounts
refetchOnReconnect: true - Refetch after network reconnect
Step 3: Wrap App with 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 placement:
Must wrap all components that use TanStack Query hooks
DevTools must be inside provider
Only one QueryClient instance for entire app
DevTools configuration:
initialIsOpen={false} - Collapsed by default
buttonPosition="bottom-right" - Where to show toggle button
Automatically removed in production builds (tree-shaken)
Step 4: Create Custom Query Hooks
Pattern: Reusable Query 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
})
}
Why use queryOptions factory:
Type inference works perfectly
Reusable across useQuery, useSuspenseQuery, prefetchQuery
Consistent queryKey and queryFn everywhere
Easier to test and maintain
Query key structure:
['todos'] - List of all todos
['todos', id] - Single todo detail
['todos', 'filters', { status: 'completed' }] - Filtered list
More specific keys are subsets (invalidating ['todos'] invalidates all)
Step 5: Implement Mutations with Optimistic Updates
// 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)
)
},
})
}
Optimistic update pattern:
onMutate: Cancel queries, snapshot old data, update cache optimistically
onError: Rollback to snapshot if mutation fails
onSettled: Refetch to ensure cache matches server
When to use:
Immediate UI feedback for better UX
Low-risk mutations (todo toggle, like button)
Avoid for critical data (payments, account settings)
Step 6: Set Up 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 features:
View all queries and their states
See query cache contents
Manually refetch queries
View mutations in flight
Inspect query dependencies
Export state for debugging
Step 7: Configure Error Boundaries
// 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
},
})
}
Error handling strategies:
Local handling: Use isError and error from query
Global handling: Use error boundaries with throwOnError
// Not allowed:
useSuspenseQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
enabled: !!id, // ❌ Not available with suspense
})
// Use conditional rendering instead:
{id && <TodoComponent id={id} />}
Known Issues Prevention
This skill prevents 8 documented issues from v5 migration and common mistakes:
Issue #1: Object Syntax Required
Error: useQuery is not a function or type errors
Source: v5 Migration GuideWhy It Happens: v5 removed all function overloads, only object syntax works
Prevention: Always use useQuery({ queryKey, queryFn, ...options })
Error: Callbacks don't run, TypeScript errors
Source: v5 Breaking ChangesWhy It Happens: onSuccess, onError, onSettled removed from queries (still work in mutations)
Prevention: Use useEffect for side effects, or move logic to mutation callbacks
Error: UI shows wrong loading state
Source: v5 Migration: isLoading renamedWhy It Happens: status: 'loading' renamed to status: 'pending', isLoading meaning changed
Prevention: Use isPending for initial load, isLoading for "pending AND fetching"
const { data, isPending, isLoading } = useQuery(...)
if (isPending) return <div>Loading...</div>
// isLoading = isPending && isFetching (fetching for first time)
Issue #4: cacheTime → gcTime
Error: cacheTime is not a valid optionSource: v5 Migration: gcTimeWhy It Happens: Renamed to better reflect "garbage collection time"
Prevention: Use gcTime instead of cacheTime
Error: Type error, enabled option not available
Source: GitHub Discussion #6206Why It Happens: Suspense guarantees data is available, can't conditionally disable
Prevention: Use conditional rendering instead of enabled option
// 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>
}
Issue #6: initialPageParam Required
Error: initialPageParam is required type error
Source: v5 Migration: Infinite QueriesWhy It Happens: v4 passed undefined as first pageParam, v5 requires explicit value
Prevention: Always specify initialPageParam for infinite queries
Error: keepPreviousData is not a valid optionSource: v5 Migration: placeholderDataWhy It Happens: Replaced with more flexible placeholderData function
Prevention: Use placeholderData: keepPreviousData helper
Error: Type errors with error handling
Source: v5 Migration: Error TypesWhy It Happens: v4 used unknown, v5 defaults to Error type
Prevention: If throwing non-Error types, specify error type explicitly
typescript-patterns.md - Type safety, generics, type inference
testing.md - Testing with MSW, React Testing Library
top-errors.md - All 8+ errors with solutions
When Claude should load these:
v4-to-v5-migration.md - When migrating existing React Query v4 project
best-practices.md - When optimizing performance or avoiding waterfalls
common-patterns.md - When implementing specific features (infinite scroll, etc.)
typescript-patterns.md - When dealing with TypeScript errors or type inference
testing.md - When writing tests for components using TanStack Query
top-errors.md - When encountering errors not covered in main SKILL.md
Advanced Topics
Data Transformations with 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>
)
}
Benefits:
Component only re-renders when selected data changes
Reduces memory usage (less data stored in component state)
Keeps query cache unchanged (other components get full data)
Request Waterfalls (Anti-Pattern)
// ❌ 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!
}
Server State vs Client State
// ❌ 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,
})
Rule of thumb:
Server state: Use TanStack Query (data from API)
Client state: Use useState/useReducer (local UI state)
Global client state: Use Zustand/Context (theme, auth token)