tanstack-query
TanStack Query Best Practices
Patterns and conventions for TanStack Query v5 data fetching, caching, and mutations in React/TypeScript projects.
Core Mental Model
- Server state vs client state — TanStack Query manages server state (data from APIs). Do not duplicate it into
useStateor Redux; use those only for client state (UI state, form drafts). - Stale-while-revalidate — Cached data is served immediately, then validated in the background. Users see data fast; freshness happens transparently.
- Query keys = dependency arrays — Every variable that affects fetching belongs in the key. When the key changes, TanStack Query refetches automatically.
- Declarative over imperative — Drive refetching by changing key values (state, filters, IDs), not by calling
refetch()with new parameters. - Cache is the single source of truth — The query cache is the authoritative store for all server state. Read from it; never copy it elsewhere.
Quick Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute — adjust per project
},
},
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
Set staleTime globally instead of disabling individual refetch flags (refetchOnMount, refetchOnWindowFocus). Those flags are features — staleTime controls when they activate.
Important: Create the QueryClient outside the component body, or stabilize it with useState:
// Inside a component — use useState to prevent new cache on every render
const [queryClient] = useState(() => new QueryClient({ /* ... */ }))
Custom Hooks + queryOptions
Always wrap queries in custom hooks. Use queryOptions to co-locate key + function in a reusable, type-safe object:
import { queryOptions, useQuery } from '@tanstack/react-query'
function todosQueryOptions(filters: TodoFilters) {
return queryOptions({
queryKey: ['todos', 'list', filters],
queryFn: () => fetchTodos(filters),
})
}
export function useTodos(filters: TodoFilters) {
return useQuery(todosQueryOptions(filters))
}
queryOptions enables reuse across useQuery, useSuspenseQuery, prefetchQuery, ensureQueryData, and invalidateQueries — all from a single definition.
Query Key Factories
Structure keys hierarchically from general to specific. Use queryOptions at fetchable levels, plain arrays at invalidation-only levels:
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) =>
queryOptions({
queryKey: [...todoKeys.lists(), filters] as const,
queryFn: () => fetchTodos(filters),
}),
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) =>
queryOptions({
queryKey: [...todoKeys.details(), id] as const,
queryFn: () => fetchTodo(id),
}),
}
Invalidation at different granularities:
// All todos (lists + details)
queryClient.invalidateQueries({ queryKey: todoKeys.all })
// All lists (any filter)
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
// One specific list
queryClient.invalidateQueries({ queryKey: todoKeys.list(filters).queryKey })
// All details
queryClient.invalidateQueries({ queryKey: todoKeys.details() })
// One specific detail
queryClient.invalidateQueries({ queryKey: todoKeys.detail(id).queryKey })
Co-locate key factories with their feature, not in a global queryKeys.ts.
Dependent Queries
Two patterns for queries that depend on a value that may not exist yet:
skipToken (preferred for TypeScript):
import { skipToken, useQuery } from '@tanstack/react-query'
export function useUser(userId: number | undefined) {
return useQuery({
queryKey: ['users', userId],
queryFn: userId ? () => fetchUser(userId) : skipToken,
})
}
enabled option:
export function useUser(userId: number | undefined) {
return useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId!),
enabled: !!userId,
})
}
skipToken is better because TypeScript narrows userId inside the truthy branch — no non-null assertion needed.
Data Transformations with select
Use select for derived data and partial subscriptions. Components only re-render when the selected value changes:
// Only re-renders when the count changes
export function useTodoCount(filters: TodoFilters) {
return useQuery({
...todosQueryOptions(filters),
select: (data) => data.length,
})
}
Stabilize references — inline select functions create new references every render:
// Extract outside component (no deps)
const selectTodoCount = (data: Todo[]) => data.length
// Or wrap in useCallback (with deps)
const select = useCallback(
(data: Todo[]) => data.filter((t) => t.priority >= minPriority),
[minPriority],
)
Status Checks: Data First
Check data before error to avoid replacing stale data with an error screen during background refetches:
const todos = useTodos(filters)
if (todos.data) return <TodoList todos={todos.data} />
if (todos.error) return <ErrorMessage error={todos.error} />
return <Loading />
Background refetch failures set error while stale data remains available. Checking error first hides perfectly good cached data.
Mutations
Use useMutation + query invalidation as the default strategy:
import { useMutation, useQueryClient } from '@tanstack/react-query'
export function useAddTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (newTodo: NewTodo) => createTodo(newTodo),
onSuccess: () => {
return queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
},
})
}
Return the promise from invalidateQueries in onSuccess to keep the mutation in loading state until the refetch completes.
Mutation strategy decision table
| Strategy | When | Complexity |
|---|---|---|
| Invalidation | Default for all mutations | Low |
| Direct cache update | Response returns full updated entity | Medium |
| Optimistic update | Instant feedback essential, rarely fails | High — see optimistic-updates.md |
Callback separation
Place query/cache logic in useMutation callbacks and UI logic in mutate() callbacks:
// Hook — cache concerns
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: () => {
return queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
},
})
// Component — UI concerns
mutation.mutate(updatedTodo, {
onSuccess: () => {
toast.success('Saved!')
navigate('/todos')
},
})
Prefer mutate over mutateAsync. mutate handles errors automatically. Only use mutateAsync when composing multiple dependent mutations.
Pass multiple variables as a single object — mutate accepts only one argument:
mutation.mutate({ id: 1, title: 'Updated' }) // correct
TypeScript
-
Let inference work. Type the
queryFnreturn, notuseQuerygenerics. AvoiduseQuery<Todo>(). -
Validate at the boundary. Use Zod in
queryFnfor runtime type safety:const todoSchema = z.object({ id: z.number(), title: z.string(), done: z.boolean() }) const fetchTodo = async (id: number) => { const res = await fetch(`/api/todos/${id}`) if (!res.ok) throw new Error('Failed to fetch') return todoSchema.parse(await res.json()) } -
Use
skipTokenfor type-safe dependent queries (narrows types without!). -
Register global error types via module augmentation:
declare module '@tanstack/react-query' { interface Register { defaultError: AxiosError } } -
Don't destructure if you need type narrowing —
const query = useTodos()thenif (query.isSuccess)narrowsquery.data.
Error Handling
Three complementary strategies:
1. Per-query error property (inline)
if (query.error) return <ErrorMessage error={query.error} />
2. Error boundaries with throwOnError
Use a function for granular control — propagate 5xx to boundaries, handle 4xx locally:
useQuery({
...todosQueryOptions(filters),
throwOnError: (error) => error.status >= 500,
})
3. Global toasts via QueryCache.onError
Show toasts only for background refetch failures (stale data exists):
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (query.state.data !== undefined) {
toast.error(`Background update failed: ${error.message}`)
}
},
}),
})
Fetch API gotcha
fetch does not reject on 4xx/5xx. You must throw manually:
const res = await fetch(url)
if (!res.ok) throw new Error(`Request failed: ${res.status}`)
return res.json()
Anti-Patterns
1. Copying server state to useState
// Don't
const { data } = useTodos()
const [todos, setTodos] = useState(data) // stale copy, never updates
// Do — use query data directly
const { data: todos } = useTodos()
2. Imperative refetching in useEffect
// Don't
useEffect(() => { refetch() }, [filters])
// Do — put filters in the query key
useQuery({ queryKey: ['todos', filters], queryFn: () => fetchTodos(filters) })
3. Disabling refetch flags
// Don't
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, refetchOnWindowFocus: false, refetchOnMount: false })
// Do — set staleTime to control freshness
useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 1000 * 60 * 5 })
4. Using query cache as local state manager
Don't call setQueryData for client-only state. Background refetches overwrite manual cache writes.
5. Object rest destructuring
// Don't — defeats tracked queries, re-renders on every field change
const { data, ...rest } = useQuery(...)
// Do — destructure only what you need
const { data, isPending } = useQuery(...)
6. Inline selectors without stable references
// Don't — new function every render
useQuery({ ...opts, select: (d) => d.filter(expensiveCheck) })
// Do — extract or useCallback
const select = useCallback((d: Todo[]) => d.filter(expensiveCheck), [])
7. Manual generic specification on useQuery
// Don't
useQuery<Todo[], Error, Todo[], ['todos']>({ ... })
// Do — let inference flow from queryFn return type
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
8. Checking error before data
// Don't — hides cached data on background refetch error
if (error) return <Error />
if (data) return <Data />
// Do — show cached data even when background refetch fails
if (data) return <Data />
if (error) return <Error />
Configuration Reference
| Option | Default | Purpose |
|---|---|---|
staleTime |
0 |
How long data is considered fresh (ms). Fresh data is never refetched. |
gcTime |
5 min |
How long inactive cache entries are kept before garbage collection. |
retry |
3 |
Number of retry attempts on failure. Set false in tests. |
enabled |
true |
Set false to disable automatic fetching. Prefer skipToken. |
refetchOnWindowFocus |
true |
Refetch stale queries on window focus. Controlled by staleTime. |
throwOnError |
false |
Propagate errors to nearest error boundary. Accepts a function. |
networkMode |
'online' |
'online' / 'always' / 'offlineFirst'. Controls fetch behavior when offline. |
placeholderData |
— | Shown while real data loads. Use keepPreviousData for pagination. |
Specialized Guides
Open these only when the task requires them:
- Optimistic Updates — When implementing optimistic UI, direct cache manipulation, or concurrent mutations
- Testing — When writing tests for queries, mutations, or components using TanStack Query
- Suspense & SSR — When using
useSuspenseQuery, prefetching in loaders, or server-side rendering - Realtime & Advanced Patterns — When integrating WebSockets, infinite scrolling, offline support, or form state management
More from kbravh/skills
svg-logo-creator
Create professional SVG logos from concept briefs or descriptions. Use when generating SVG logo files, creating logo variations (horizontal, vertical, icon-only), or implementing logo designs. Triggers on "create SVG logo," "generate logo," "make a logo," "logo SVG," "design a logo," or when given a logo concept brief from logo-ideation.
70logo-ideation
Brand discovery and logo concept development. Use when brainstorming logo ideas, exploring visual directions, analyzing competitor logos, or developing logo concepts before creation. Triggers on "logo ideas," "logo concept," "brand identity," "logo brainstorm," "competitor logos," "visual identity exploration," or any pre-design logo planning.
24prisma-expand-contract
Safe database schema migrations using the expand-and-contract pattern with Prisma ORM. Use when renaming columns/tables, changing column types, adding non-nullable columns, or any schema change requiring zero-downtime deployment.
9