Skip to main content
tanstack-query Provides TanStack Query v5 patterns for query keys, mutations, optimistic updates, and MSW testing. Use when working on server state, data fetching, cache invalidation, or useQuery/useMutation hooks.
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/MadAppGang/magus --skill tanstack-queryThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository Picks the browser-use agent brain LLM per task. Use when choosing a model for retry_with_browser_use_agent or a cloud session, or the user mentions a cheap, slow, or smart model.
Reference for the Browser Use MCP tools — parameters, returns, session lifecycle. Use when navigating, clicking, typing, evaluating JS, or extracting page content.
Visual UI debugging — screenshot capture and analysis, responsive layout checking at multiple viewport sizes, CSS validation via DOM state, visual regression detection, before/after state comparison.
SOC
Based on SOC occupation classification
name tanstack-query description Provides TanStack Query v5 patterns for query keys, mutations, optimistic updates, and MSW testing. Use when working on server state, data fetching, cache invalidation, or useQuery/useMutation hooks. disable-model-invocation true
TanStack Query v5 - Complete Guide
TanStack Query v5 (October 2023) is the async state manager for this project. It requires React 18+, features first-class Suspense support, improved TypeScript inference, and a 20% smaller bundle. This section covers production-ready patterns based on official documentation and community best practices.
Breaking Changes in v5
Key updates you need to know:
Single Object Signature : All hooks now accept one configuration object:
useQuery ({ queryKey, queryFn, ...options })
useQuery (queryKey, queryFn, options)
Renamed Options :
cacheTime → gcTime (garbage collection time)
keepPreviousData → placeholderData: keepPreviousData
isLoading now means isPending && isFetching
Callbacks Removed from useQuery :
onSuccess
onError
onSettled
useQuery
Use global QueryCache callbacks instead
Prevents duplicate executions
Infinite Queries Require initialPageParam :
No default value provided
Must explicitly set initialPageParam (e.g., 0 or null)
First-Class Suspense :
New dedicated hooks: useSuspenseQuery, useSuspenseInfiniteQuery
No experimental flag needed
Data is never undefined at type level
Migration : Use the official codemod for automatic migration: npx @tanstack/query-codemods v5/replace-import-specifier
Smart Defaults Query v5 ships with production-ready defaults:
{
staleTime : 0 ,
gcTime : 5 * 60_000 ,
retry : 3 ,
refetchOnWindowFocus : true ,
refetchOnReconnect : true ,
}
Philosophy : React Query is an async state manager, not a data fetcher . You provide the Promise; Query manages caching, background updates, and synchronization.
Client Setup
import { QueryClient , QueryClientProvider , QueryCache } from '@tanstack/react-query'
import { toast } from './toast'
const queryClient = new QueryClient ({
defaultOptions : {
queries : {
staleTime : 0 ,
gcTime : 5 * 60_000 ,
retry : (failureCount, error ) => {
if (error?.response ?.status === 401 ) return false
return failureCount < 3
},
},
},
queryCache : new QueryCache ({
onError : (error, query ) => {
if (query.state .data !== undefined ) {
toast.error (`Something went wrong: ${error.message} ` )
}
},
}),
})
export function AppProviders ({ children }: { children: React.ReactNode } ) {
return (
<QueryClientProvider client ={queryClient} >
{children}
</QueryClientProvider >
)
}
DevTools Setup (auto-excluded in production):
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false } />
</QueryClientProvider >
Server-Side Rendering Configuration When using TanStack Query with SSR (Next.js, Remix, TanStack Start), configure server-specific defaults:
export function makeQueryClient ( ) {
return new QueryClient ({
defaultOptions : {
queries : {
retry : typeof window === 'undefined' ? 0 : 3 ,
staleTime : 60_000 ,
},
},
})
}
Server vs Client Defaults:
Option Client Default Server Recommended Why retry3 0 Server should fail fast, not retry loops staleTime0 60_000+ Server-rendered data is fresh gcTime5 min Infinity No garbage collection needed on server refetchOnWindowFocustrue false No window on server refetchOnReconnecttrue false No reconnect on server
Important: In SPA-only apps (TanStack Router + Vite), you don't need these server defaults. They're only relevant for SSR frameworks.
Streaming SSR (Experimental) For Next.js App Router, @tanstack/react-query-next-experimental enables streaming:
pnpm add @tanstack/react-query-next-experimental
'use client'
import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'
function makeQueryClient ( ) {
return new QueryClient ({
defaultOptions : {
queries : { staleTime : 60_000 },
},
})
}
let browserQueryClient : QueryClient | undefined
function getQueryClient ( ) {
if (typeof window === 'undefined' ) {
return makeQueryClient ()
}
return (browserQueryClient ??= makeQueryClient ())
}
export function Providers ({ children }: { children: React.ReactNode } ) {
const queryClient = getQueryClient ()
return (
<QueryClientProvider client ={queryClient} >
<ReactQueryStreamedHydration > {children}</ReactQueryStreamedHydration >
</QueryClientProvider >
)
}
Usage in Client Components:
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
export function UserProfile ({ userId }: { userId: string } ) {
const { data : user } = useSuspenseQuery ({
queryKey : ['user' , userId],
queryFn : () => fetchUser (userId),
})
return <div > {user.name}</div >
}
Skip manual prefetching in Server Components
Data streams to client as it resolves
Suspense boundaries show loading states naturally
Next.js App Router only (experimental)
Not for TanStack Router SPAs (use route loaders instead)
Server Components Integration When you have React Server Components (RSC) , how does TanStack Query fit?
The Mental Model Think of Server Components as another framework loader (like route loaders):
Feature Server Components TanStack Query Initial data fetch Yes (server) Yes (client prefetch) Client mutations No Yes Background refetch No Yes Optimistic updates No Yes Real-time updates No Yes Cache management No Yes
When TanStack Query is Still Valuable Even in RSC-heavy apps, Query remains essential for:
Client-Side Mutations
export default async function PostPage ({ params } ) {
const post = await fetchPost (params.id )
return <PostWithComments post ={post} />
}
'use client'
function PostWithComments ({ post } ) {
const addComment = useMutation ({ ... })
}
Background Refetching After Initial Load
Optimistic Updates
const likeMutation = useMutation ({
mutationFn : likePost,
onMutate : async () => {
},
})
Real-Time Updates
useQuery ({
queryKey : ['notifications' ],
queryFn : fetchNotifications,
refetchInterval : 30_000 ,
})
Recommended Pattern
export default async function DashboardPage ( ) {
const initialData = await fetchDashboard ()
return (
<HydrationBoundary state ={dehydrate(queryClient)} >
<DashboardClient initialData ={initialData} />
</HydrationBoundary >
)
}
'use client'
function DashboardClient ({ initialData } ) {
const { data } = useQuery ({
queryKey : ['dashboard' ],
queryFn : fetchDashboard,
initialData,
})
const updateWidget = useMutation ({ ... })
}
SPA Recommendation For SPA-only apps (TanStack Router + Vite) : Server Components don't apply. Use TanStack Query as your primary data layer with route loaders for prefetching.
Query + React 19 Actions When using React 19 Actions alongside Query, keep responsibilities clear:
Complementary Usage
const { data : posts } = useQuery ({
queryKey : ['posts' ],
queryFn : fetchPosts,
})
async function createPostAction (formData : FormData ) {
'use server'
const result = await createPost (formData)
return result
}
const [state, formAction] = useActionState (async (prev, formData) => {
const result = await createPostAction (formData)
if (result.success ) {
queryClient.invalidateQueries ({ queryKey : ['posts' ] })
}
return result
}, { success : false })
Decision Matrix Use Case Recommendation Data fetching Query (useQuery) List caching Query Form submission Action (useActionState) + Query invalidation Button click mutation Query (useMutation) Optimistic update with rollback Query (useMutation) Server-side validation Action Complex multi-step mutations Query (useMutation)
Rule of Thumb
Actions for form submissions with server-side validation
Query for everything else (fetching, caching, complex mutations)
Both when forms need to update Query cache after success
Architecture: Feature-Based Colocation Recommended pattern : Group queries with related features, not by file type.
src/features/
├── Todos/
│ ├── index.tsx # Feature entry point
│ ├── queries.ts # All React Query logic (keys, functions, hooks)
│ ├── types.ts # TypeScript types
│ └── components/ # Feature-specific components
Export only custom hooks from query files. Keep query functions and keys private:
const todoKeys = {
all : ['todos' ] as const ,
lists : () => [...todoKeys.all , 'list' ] as const ,
list : (filters : string ) => [...todoKeys.lists (), { filters }] as const ,
details : () => [...todoKeys.all , 'detail' ] as const ,
detail : (id : number ) => [...todoKeys.details (), id] as const ,
}
const fetchTodos = async (filters : string ): Promise <Todo []> => {
const response = await axios.get ('/api/todos' , { params : { filters } })
return response.data
}
export const useTodosQuery = (filters : string ) => {
return useQuery ({
queryKey : todoKeys.list (filters),
queryFn : () => fetchTodos (filters),
staleTime : 30_000 ,
})
}
Prevents key/function mismatches
Clean public API
Encapsulation and maintainability
Easy to locate all query logic for a feature
Query Key Factories (Essential) Structure keys hierarchically from generic to specific:
['todos' ]
['todos' , 'list' ]
['todos' , 'list' , { filters }]
['todos' , 'detail' , 1 ]
['todos-list-active' ]
Critical rule : Query keys must include ALL variables used in queryFn . Treat query keys like dependency arrays:
const { data } = useQuery ({
queryKey : ['todos' , filters, sortBy],
queryFn : () => fetchTodos (filters, sortBy),
})
const { data } = useQuery ({
queryKey : ['todos' ],
queryFn : () => fetchTodos (filters, sortBy),
})
Type consistency matters : ['todos', '1'] and ['todos', 1] are different keys . Be consistent with types.
Query Options API (Type Safety) The modern pattern for maximum type safety across your codebase:
import { queryOptions } from '@tanstack/react-query'
function todoOptions (id : number ) {
return queryOptions ({
queryKey : ['todos' , id],
queryFn : () => fetchTodo (id),
staleTime : 5000 ,
})
}
useQuery (todoOptions (1 ))
queryClient.prefetchQuery (todoOptions (5 ))
queryClient.setQueryData (todoOptions (42 ).queryKey , newTodo)
queryClient.getQueryData (todoOptions (42 ).queryKey )
Single source of truth for query configuration
Full TypeScript inference for imperatively accessed data
Reusable across hooks and imperative methods
Prevents key/function mismatches
Data Transformation Strategies Choose the right approach based on your use case:
1. Transform in queryFn - Simple cases where cache should store transformed data:
const fetchTodos = async (): Promise <Todo []> => {
const response = await axios.get ('/api/todos' )
return response.data .map (todo => ({
...todo,
name : todo.name .toUpperCase ()
}))
}
2. Transform with select option (RECOMMENDED) - Enables partial subscriptions:
export const useTodosQuery = (filters : string ) =>
useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
select : (data ) => data.filter (todo => todo.status === filters),
})
export const useTodosCount = ( ) =>
useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
select : (data ) => data.length ,
})
⚠️ Memoize select functions to prevent running on every render:
const transformTodos = (data : Todo [] ) => expensiveTransform (data)
const query = useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
select : transformTodos,
})
const query = useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
select : (data ) => expensiveTransform (data),
})
TypeScript Best Practices Let TypeScript infer types from queryFn rather than specifying generics:
const { data } = useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
})
const { data } = useQuery<Todo []>({
queryKey : ['todos' ],
queryFn : fetchTodos,
})
Discriminated unions automatically narrow types:
const { data, isSuccess, isError, error } = useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
})
if (isSuccess) {
}
if (isError) {
}
Use queryOptions helper for maximum type safety across imperative methods.
Custom Hooks Pattern Always create custom hooks even for single queries:
export function usePost (
id : number ,
options ?: Omit <UseQueryOptions <Post >, 'queryKey' | 'queryFn' >
) {
return useQuery ({
queryKey : ['posts' , id],
queryFn : () => getPost (id),
...options,
})
}
const { data } = usePost (42 , { staleTime : 10_000 })
Centralizes query logic
Easy to update all usages
Consistent configuration
Better testing
Error Handling (Multi-Layer Strategy) Layer 1: Component-Level - Specific user feedback:
function TaskList ( ) {
const { data, error, isError, isLoading } = useQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
})
if (isLoading) return <Spinner />
if (isError) return <ErrorAlert > {error.message}</ErrorAlert >
return <ul > {data.map(todo => <TodoItem key ={todo.id} {...todo } /> )}</ul >
}
Layer 2: Global Error Handling - Background errors via QueryCache:
queryCache : new QueryCache ({
onError : (error, query ) => {
if (query.state .data !== undefined ) {
toast.error (`Background error: ${error.message} ` )
}
},
})
Layer 3: Error Boundaries - Catch render errors:
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
<QueryErrorResetBoundary >
{({ reset } ) => (
<ErrorBoundary
onReset ={reset}
fallbackRender ={({ error , resetErrorBoundary }) => (
<div >
<p > Error: {error.message}</p >
<button onClick ={resetErrorBoundary} > Try again</button >
</div >
)}
>
<TaskList />
</ErrorBoundary >
)}
</QueryErrorResetBoundary >
Suspense Integration First-class Suspense support in v5 with dedicated hooks:
import { useSuspenseQuery } from '@tanstack/react-query'
function TaskList ( ) {
const { data } = useSuspenseQuery ({
queryKey : ['todos' ],
queryFn : fetchTodos,
})
return <ul > {data.map(todo => <TodoItem key ={todo.id} {...todo } /> )}</ul >
}
function App ( ) {
return (
<Suspense fallback ={ <Spinner /> }>
<TaskList />
</Suspense >
)
}
Eliminates loading state management
Data always defined (TypeScript enforced)
Cleaner component code
Works with React.lazy for code-splitting
Mutations with Optimistic Updates Basic mutation with cache invalidation:
export function useCreateTodo ( ) {
const queryClient = useQueryClient ()
return useMutation ({
mutationFn : (newTodo : CreateTodoDTO ) =>
api.post ('/todos' , newTodo).then (res => res.data ),
onSuccess : (data ) => {
queryClient.setQueryData (['todos' , data.id ], data)
queryClient.invalidateQueries ({ queryKey : ['todos' , 'list' ] })
},
})
}
Simple optimistic updates using variables:
const addTodoMutation = useMutation ({
mutationFn : (newTodo : string ) => axios.post ('/api/todos' , { text : newTodo }),
onSettled : () => queryClient.invalidateQueries ({ queryKey : ['todos' ] }),
})
const { isPending, variables, mutate } = addTodoMutation
return (
<ul >
{todoQuery.data?.map(todo => <li key ={todo.id} > {todo.text}</li > )}
{isPending && <li style ={{ opacity: 0.5 }}> {variables}</li > }
</ul >
)
Advanced optimistic updates with rollback:
useMutation ({
mutationFn : updateTodo,
onMutate : async (newTodo) => {
await queryClient.cancelQueries ({ queryKey : ['todos' ] })
const previousTodos = queryClient.getQueryData (['todos' ])
queryClient.setQueryData (['todos' ], (old : Todo [] ) =>
old?.map (todo => todo.id === newTodo.id ? newTodo : todo)
)
return { previousTodos }
},
onError : (err, newTodo, context ) => {
queryClient.setQueryData (['todos' ], context?.previousTodos )
toast.error ('Update failed. Changes reverted.' )
},
onSettled : () => {
queryClient.invalidateQueries ({ queryKey : ['todos' ] })
},
})
Cancel ongoing queries in onMutate to prevent race conditions
Snapshot previous data before updating
Restore snapshot on error
Always invalidate in onSettled for eventual consistency
Never mutate cached data directly - always use immutable updates
Authentication Integration Handle token refresh at HTTP client level (not React Query):
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
export const apiClient = axios.create ({
baseURL : import .meta .env .VITE_API_URL ,
})
apiClient.interceptors .request .use ((config ) => {
const token = getAccessToken ()
if (token) config.headers .Authorization = `Bearer ${token} `
return config
})
const refreshAuth = async (failedRequest : any ) => {
try {
const newToken = await fetchNewToken ()
failedRequest.response .config .headers .Authorization = `Bearer ${newToken} `
setAccessToken (newToken)
return Promise .resolve ()
} catch {
removeAccessToken ()
window .location .href = '/login'
return Promise .reject ()
}
}
createAuthRefreshInterceptor (apiClient, refreshAuth, {
statusCodes : [401 ],
pauseInstanceWhileRefreshing : true ,
})
Protected queries use the enabled option:
const useTodos = ( ) => {
const { user } = useUser ()
return useQuery ({
queryKey : ['todos' , user?.id ],
queryFn : () => fetchTodos (user.id ),
enabled : !!user,
})
}
On logout : Clear the entire cache with queryClient.clear() (not invalidateQueries() which triggers refetches):
const logout = ( ) => {
removeAccessToken ()
queryClient.clear ()
navigate ('/login' )
}
Advanced Patterns Prefetching - Eliminate loading states:
function ShowDetailsButton ( ) {
const queryClient = useQueryClient ()
const prefetch = ( ) => {
queryClient.prefetchQuery ({
queryKey : ['details' ],
queryFn : getDetailsData,
staleTime : 60_000 ,
})
}
return (
<button onMouseEnter ={prefetch} onClick ={showDetails} >
Show Details
</button >
)
}
Infinite Queries - Infinite scrolling/pagination:
function Projects ( ) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery ({
queryKey : ['projects' ],
queryFn : ({ pageParam } ) => fetchProjects (pageParam),
initialPageParam : 0 ,
getNextPageParam : (lastPage ) => lastPage.nextCursor ,
})
if (isLoading) return <Spinner />
return (
<>
{data.pages.map((page, i) => (
<React.Fragment key ={i} >
{page.data.map(project => (
<ProjectCard key ={project.id} {...project } />
))}
</React.Fragment >
))}
<button
onClick ={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button >
</>
)
}
Offset-Based Pagination with placeholderData:
import { keepPreviousData } from '@tanstack/react-query'
function Posts ( ) {
const [page, setPage] = useState (0 )
const { data, isPending, isPlaceholderData } = useQuery ({
queryKey : ['posts' , page],
queryFn : () => fetchPosts (page),
placeholderData : keepPreviousData,
})
return (
<>
{data.posts.map(post => <PostCard key ={post.id} {...post } /> )}
<button
onClick ={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
Previous
</button >
<button
onClick ={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data.hasMore}
>
Next
</button >
</>
)
}
Dependent Queries - Sequential data fetching:
function UserProjects ({ email }: { email: string } ) {
const { data : user } = useQuery ({
queryKey : ['user' , email],
queryFn : () => getUserByEmail (email),
})
const { data : projects } = useQuery ({
queryKey : ['projects' , user?.id ],
queryFn : () => getProjectsByUser (user.id ),
enabled : !!user?.id ,
})
return <div > {/* render projects */}</div >
}
Performance Optimization staleTime is your primary control - adjust this, not gcTime:
staleTime : 0
staleTime : 1000 * 60 * 2
staleTime : 1000 * 60 * 10
Query deduplication happens automatically - multiple components mounting with identical query keys result in a single network request, but all components receive data.
Prevent request waterfalls :
function Dashboard ( ) {
const { data : user } = useQuery (userQuery)
const { data : posts } = useQuery (postsQuery (user?.id ))
const { data : stats } = useQuery (statsQuery (user?.id ))
}
function Dashboard ( ) {
const { data : user } = useQuery (userQuery)
const { data : posts } = useQuery ({
...postsQuery (user?.id ),
enabled : !!user?.id ,
})
const { data : stats } = useQuery ({
...statsQuery (user?.id ),
enabled : !!user?.id ,
})
}
Never copy server state to local state - this opts out of background updates:
const { data } = useQuery ({ queryKey : ['todos' ], queryFn : fetchTodos })
const [todos, setTodos] = useState (data)
const { data : todos } = useQuery ({ queryKey : ['todos' ], queryFn : fetchTodos })
Testing with Mock Service Worker (MSW) MSW is the recommended approach - mock the network layer:
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get ('/api/todos' , () => {
return HttpResponse .json ([
{ id : 1 , text : 'Test todo' , completed : false },
])
}),
http.post ('/api/todos' , async ({ request }) => {
const newTodo = await request.json ()
return HttpResponse .json ({ id : 2 , ...newTodo })
}),
]
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
export const server = setupServer (...handlers)
beforeAll (() => server.listen ())
afterEach (() => server.resetHandlers ())
afterAll (() => server.close ())
Create test wrappers with proper QueryClient:
import { QueryClient , QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
export function createTestQueryClient ( ) {
return new QueryClient ({
defaultOptions : {
queries : {
retry : false ,
gcTime : Infinity ,
},
},
})
}
export function renderWithClient (ui : React .ReactElement ) {
const testQueryClient = createTestQueryClient ()
return render (
<QueryClientProvider client ={testQueryClient} >
{ui}
</QueryClientProvider >
)
}
import { renderWithClient } from '@/test/utils'
import { screen } from '@testing-library/react'
test ('displays todos' , async () => {
renderWithClient (<TaskList /> )
expect (await screen.findByText ('Test todo' )).toBeInTheDocument ()
})
test ('shows error state' , async () => {
server.use (
http.get ('/api/todos' , () => {
return HttpResponse .json (
{ message : 'Failed to fetch' },
{ status : 500 }
)
})
)
renderWithClient (<TaskList /> )
expect (await screen.findByText (/failed/i )).toBeInTheDocument ()
})
Critical testing principles :
Create new QueryClient per test for isolation
Set retry: false to prevent timeouts
Use async queries (findBy*) for data that loads
Silence console.error for expected errors
Anti-Patterns to Avoid ❌ Don't store query data in Redux/Context :
Creates dual sources of truth
Loses automatic cache invalidation
Triggers unnecessary renders
❌ Don't call refetch() with different parameters :
const { data, refetch } = useQuery ({
queryKey : ['todos' ],
queryFn : () => fetchTodos (filters),
})
const [filters, setFilters] = useState ('all' )
const { data } = useQuery ({
queryKey : ['todos' , filters],
queryFn : () => fetchTodos (filters),
})
❌ Don't use queries for local state :
Query Cache expects refetchable data
Use useState/useReducer for client-only state
❌ Don't create QueryClient inside components :
function App ( ) {
const client = new QueryClient ()
return <QueryClientProvider client ={client} > ...</QueryClientProvider >
}
const queryClient = new QueryClient ()
function App ( ) {
return <QueryClientProvider client ={queryClient} > ...</QueryClientProvider >
}
❌ Don't ignore loading and error states - always handle both
❌ Don't transform data by copying to state - use select option
❌ Don't mismatch query keys - be consistent with types ('1' vs 1)
Cache Timing Guidelines staleTime - How long data is considered fresh:
0 (default) - Always stale, refetch on mount/focus
30_000 (30s) - Good for user-generated content
120_000 (2min) - Good for profile data
600_000 (10min) - Good for static reference data
gcTime (formerly cacheTime) - How long unused data stays in cache:
300_000 (5min, default) - Good for most cases
Infinity - Keep forever (useful with persistence)
0 - Immediate garbage collection (not recommended)
Relationship : staleTime controls refetch frequency, gcTime controls memory cleanup.
Related Skills
tanstack-router - File-based routing with loader prefetching
react-typescript - React 19 patterns, Actions vs Mutations decision guide
state-management - Zustand for client state, Query for server state
testing-frontend - Testing queries with MSW
api-integration - Backend API patterns with Apidog
Router Integration (TanStack Router) Seamlessly integrate TanStack Router with TanStack Query for optimal SPA performance and instant navigation.
Route Loader + Query Prefetch The key pattern: Use route loaders to prefetch queries BEFORE navigation completes.
Loaders run before render, eliminating waterfall
Fast SPA navigations (instant perceived performance)
Queries still benefit from cache deduplication
Add Router & Query DevTools during development (auto-hide in production)
Basic Pattern
import { createFileRoute } from '@tanstack/react-router'
import { queryClient } from '@/app/queryClient'
import { usersKeys, fetchUser } from '@/features/users/queries'
export const Route = createFileRoute ('/users/$id' )({
loader : async ({ params }) => {
const id = params.id
return queryClient.ensureQueryData ({
queryKey : usersKeys.detail (id),
queryFn : () => fetchUser (id),
staleTime : 30_000 ,
})
},
component : UserPage ,
})
function UserPage ( ) {
const { id } = Route .useParams ()
const { data : user } = useQuery ({
queryKey : usersKeys.detail (id),
queryFn : () => fetchUser (id),
})
return <div > {user.name}</div >
}
Using Query Options Pattern (Recommended) Query Options provide maximum type safety and DRY:
import { queryOptions } from '@tanstack/react-query'
export function userQueryOptions (userId : string ) {
return queryOptions ({
queryKey : ['users' , userId],
queryFn : () => fetchUser (userId),
staleTime : 30_000 ,
})
}
export function useUser (userId : string ) {
return useQuery (userQueryOptions (userId))
}
import { userQueryOptions } from '@/features/users/queries'
import { queryClient } from '@/app/queryClient'
export const Route = createFileRoute ('/users/$userId' )({
loader : ({ params } ) =>
queryClient.ensureQueryData (userQueryOptions (params.userId )),
component : UserPage ,
})
function UserPage ( ) {
const { userId } = Route .useParams ()
const { data : user } = useUser (userId)
return <div > {user.name}</div >
}
Multiple Queries in Loader export const Route = createFileRoute ('/dashboard' )({
loader : async () => {
await Promise .all ([
queryClient.ensureQueryData (userQueryOptions ()),
queryClient.ensureQueryData (statsQueryOptions ()),
queryClient.ensureQueryData (postsQueryOptions ()),
])
},
component : Dashboard ,
})
function Dashboard ( ) {
const { data : user } = useUser ()
const { data : stats } = useStats ()
const { data : posts } = usePosts ()
return (
<div >
<UserHeader user ={user} />
<StatsPanel stats ={stats} />
<PostsList posts ={posts} />
</div >
)
}
Dependent Queries in Loaders export const Route = createFileRoute ('/users/$userId/posts' )({
loader : async ({ params }) => {
const user = await queryClient.ensureQueryData (
userQueryOptions (params.userId )
)
return queryClient.ensureQueryData (
userPostsQueryOptions (user.id )
)
},
component : UserPostsPage ,
})
Prefetch vs Ensure prefetchQuery - Fire and forget, don't wait:
loader : ({ params } ) => {
queryClient.prefetchQuery (userQueryOptions (params.userId ))
}
ensureQueryData - Wait for data (recommended):
loader : async ({ params }) => {
return await queryClient.ensureQueryData (userQueryOptions (params.userId ))
}
fetchQuery - Always fetches fresh:
loader : async ({ params }) => {
return await queryClient.fetchQuery (userQueryOptions (params.userId ))
}
Recommendation: Use ensureQueryData for most cases - respects cache and staleTime.
Preloading on Link Hover import { Link } from '@tanstack/react-router'
<Link
to="/users/$userId"
params={{ userId : '123' }}
preload="intent"
>
View User
</Link >
Search Params + Queries
import { z } from 'zod'
const searchSchema = z.object ({
page : z.number ().default (1 ),
filter : z.enum (['active' , 'all' ]).default ('all' ),
})
export const Route = createFileRoute ('/users/' )({
validateSearch : searchSchema,
loader : ({ search } ) => {
return queryClient.ensureQueryData (
usersQueryOptions ({ page : search.page , filter : search.filter })
)
},
component : UsersPage ,
})
function UsersPage ( ) {
const { page, filter } = Route .useSearch ()
const { data : users } = useUsers ({ page, filter })
return <UsersList users ={users} />
}
Related Skills
tanstack-router - File-based routing and navigation
react-typescript - React component patterns
state-management - Client state management (Zustand)
testing-frontend - Testing queries with MSW