Comprehensive TanStack Query v5 patterns for async state management...
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.
Key updates you need to know:
Single Object Signature: All hooks now accept one configuration object:
// ✅ v5 - single object
useQuery({ queryKey, queryFn, ...options })
// ❌ v4 - multiple overloads (deprecated)
useQuery(queryKey, queryFn, options)
Renamed Options:
cacheTime → gcTime (garbage collection time)keepPreviousData → placeholderData: keepPreviousDataisLoading now means isPending && isFetchingCallbacks Removed from useQuery:
onSuccess, onError, onSettled removed from useQueryInfinite Queries Require initialPageParam:
initialPageParam (e.g., 0 or null)First-Class Suspense:
useSuspenseQuery, useSuspenseInfiniteQueryMigration: Use the official codemod for automatic migration: npx @tanstack/query-codemods v5/replace-import-specifier
Query v5 ships with production-ready defaults:
{
staleTime: 0, // Data instantly stale (refetch on mount)
gcTime: 5 * 60_000, // Keep unused cache for 5 minutes
retry: 3, // 3 retries with exponential backoff
refetchOnWindowFocus: true,// Refetch when user returns to tab
refetchOnReconnect: true, // Refetch when network reconnects
}
Philosophy: React Query is an async state manager, not a data fetcher. You provide the Promise; Query manages caching, background updates, and synchronization.
// src/app/providers.tsx
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'
import { toast } from './toast' // Your notification system
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // Adjust per-query
gcTime: 5 * 60_000, // 5 minutes (v5: formerly cacheTime)
retry: (failureCount, error) => {
// Don't retry on 401 (authentication errors)
if (error?.response?.status === 401) return false
return failureCount < 3
},
},
},
queryCache: new QueryCache({
onError: (error, query) => {
// Only show toast for background errors (when data exists)
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>
When using TanStack Query with SSR (Next.js, Remix, TanStack Start), configure server-specific defaults:
// Server-side QueryClient configuration
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// Server: Don't retry on server (fail fast)
retry: typeof window === 'undefined' ? 0 : 3,
// Server: Data is always fresh when rendered
staleTime: 60_000, // 1 minute
},
},
})
}
Server vs Client Defaults:
| Option | Client Default | Server Recommended | Why |
|---|---|---|---|
retry |
3 | 0 | Server should fail fast, not retry loops |
staleTime |
0 | 60_000+ | Server-rendered data is fresh |
gcTime |
5 min | Infinity | No garbage collection needed on server |
refetchOnWindowFocus |
true | false | No window on server |
refetchOnReconnect |
true | 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.
For Next.js App Router, @tanstack/react-query-next-experimental enables streaming:
pnpm add @tanstack/react-query-next-experimental
Setup:
// app/providers.tsx
'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() // Server: always new
}
return (browserQueryClient ??= makeQueryClient()) // Browser: singleton
}
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 }) {
// No prefetch needed! Data streams from server
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>{user.name}</div>
}
Benefits:
Limitations:
When you have React Server Components (RSC), how does TanStack Query fit?
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 |
Even in RSC-heavy apps, Query remains essential for:
Client-Side Mutations
// Server Component fetches, Client handles mutations
export default async function PostPage({ params }) {
const post = await fetchPost(params.id) // Server fetch
return <PostWithComments post={post} /> // Client mutations
}
'use client'
function PostWithComments({ post }) {
const addComment = useMutation({ ... }) // Still need Query!
// ...
}
Background Refetching After Initial Load
// Initial: Server Component renders with fresh data
// After: Query keeps data fresh on client
Optimistic Updates
// Can't do optimistic updates with Server Components alone
const likeMutation = useMutation({
mutationFn: likePost,
onMutate: async () => {
// Optimistic update - only possible with Query
},
})
Real-Time Updates
// WebSocket data, polling, etc. - client-only
useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
refetchInterval: 30_000, // Real-time polling
})
// Server Component: Initial fetch
export default async function DashboardPage() {
const initialData = await fetchDashboard()
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient initialData={initialData} />
</HydrationBoundary>
)
}
// Client Component: Mutations + real-time
'use client'
function DashboardClient({ initialData }) {
// Query hydrates from server data, then manages client state
const { data } = useQuery({
queryKey: ['dashboard'],
queryFn: fetchDashboard,
initialData,
})
const updateWidget = useMutation({ ... })
// ...
}
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.
When using React 19 Actions alongside Query, keep responsibilities clear:
// Query: Fetching and caching
const { data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// Action: Form submission with built-in validation
async function createPostAction(formData: FormData) {
'use server'
const result = await createPost(formData)
return result
}
// After action succeeds, invalidate Query cache
const [state, formAction] = useActionState(async (prev, formData) => {
const result = await createPostAction(formData)
if (result.success) {
queryClient.invalidateQueries({ queryKey: ['posts'] })
}
return result
}, { success: false })
| 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) |
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:
// features/todos/queries.ts
// 1. Query Key Factory (hierarchical structure)
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,
}
// 2. Query Function (private)
const fetchTodos = async (filters: string): Promise<Todo[]> => {
const response = await axios.get('/api/todos', { params: { filters } })
return response.data
}
// 3. Custom Hook (public API)
export const useTodosQuery = (filters: string) => {
return useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 30_000, // Fresh for 30 seconds
})
}
Benefits:
Structure keys hierarchically from generic to specific:
// ✅ Correct hierarchy
['todos'] // Invalidates everything
['todos', 'list'] // Invalidates all lists
['todos', 'list', { filters }] // Invalidates specific list
['todos', 'detail', 1] // Invalidates specific detail
// ❌ Wrong - flat structure
['todos-list-active'] // Can't partially invalidate
Critical rule: Query keys must include ALL variables used in queryFn. Treat query keys like dependency arrays:
// ✅ Correct - includes all variables
const { data } = useQuery({
queryKey: ['todos', filters, sortBy],
queryFn: () => fetchTodos(filters, sortBy),
})
// ❌ Wrong - missing variables
const { data } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters, sortBy), // filters/sortBy not in key!
})
Type consistency matters: ['todos', '1'] and ['todos', 1] are different keys. Be consistent with types.
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,
})
}
// ✅ Use everywhere with full type safety
useQuery(todoOptions(1))
queryClient.prefetchQuery(todoOptions(5))
queryClient.setQueryData(todoOptions(42).queryKey, newTodo)
queryClient.getQueryData(todoOptions(42).queryKey) // Fully typed!
Benefits:
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:
// Only re-renders when filtered data changes
export const useTodosQuery = (filters: string) =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.filter(todo => todo.status === filters),
})
// Only re-renders when count changes
export const useTodosCount = () =>
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => data.length,
})
⚠️ Memoize select functions to prevent running on every render:
// ✅ Stable reference
const transformTodos = (data: Todo[]) => expensiveTransform(data)
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: transformTodos, // Stable function reference
})
// ❌ Runs on every render
const query = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (data) => expensiveTransform(data), // New function every render
})
Let TypeScript infer types from queryFn rather than specifying generics:
// ✅ Recommended - inference
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos, // Returns Promise<Todo[]>
})
// data is Todo[] | undefined
// ❌ Unnecessary - explicit generics
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) {
// data is Todo[] (never undefined)
}
if (isError) {
// error is defined
}
Use queryOptions helper for maximum type safety across imperative methods.
Always create custom hooks even for single queries:
// ✅ Recommended - custom hook with encapsulation
export function usePost(
id: number,
options?: Omit<UseQueryOptions<Post>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: ['posts', id],
queryFn: () => getPost(id),
...options,
})
}
// Usage: allows callers to override any option except key/fn
const { data } = usePost(42, { staleTime: 10_000 })
Benefits:
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:
// Already configured in client setup above
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>
First-class Suspense support in v5 with dedicated hooks:
import { useSuspenseQuery } from '@tanstack/react-query'
function TaskList() {
// data is NEVER undefined (type-safe)
const { data } = useSuspenseQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return <ul>{data.map(todo => <TodoItem key={todo.id} {...todo} />)}</ul>
}
// Wrap with Suspense boundary
function App() {
return (
<Suspense fallback={<Spinner />}>
<TaskList />
</Suspense>
)
}
Benefits:
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) => {
// Set detail query immediately
queryClient.setQueryData(['todos', data.id], data)
// Invalidate list queries
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) => {
// Cancel outgoing queries (prevent race conditions)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// Snapshot current data
const previousTodos = queryClient.getQueryData(['todos'])
// Optimistically update cache
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old?.map(todo => todo.id === newTodo.id ? newTodo : todo)
)
// Return context for rollback
return { previousTodos }
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos)
toast.error('Update failed. Changes reverted.')
},
onSettled: () => {
// Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
})
Key principles:
onMutate to prevent race conditionsonSettled for eventual consistencyHandle token refresh at HTTP client level (not React Query):
// src/lib/api-client.ts
import axios from 'axios'
import createAuthRefreshInterceptor from 'axios-auth-refresh'
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL,
})
// Add token to requests
apiClient.interceptors.request.use((config) => {
const token = getAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// Refresh token on 401
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() // Get current user from auth context
return useQuery({
queryKey: ['todos', user?.id],
queryFn: () => fetchTodos(user.id),
enabled: !!user, // Only execute when user exists
})
}
On logout: Clear the entire cache with queryClient.clear() (not invalidateQueries() which triggers refetches):
const logout = () => {
removeAccessToken()
queryClient.clear() // Clear all cached data
navigate('/login')
}
Prefetching - Eliminate loading states:
// Hover prefetching
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
staleTime: 60_000, // Consider fresh for 1 minute
})
}
return (
<button onMouseEnter={prefetch} onClick={showDetails}>
Show Details
</button>
)
}
// Route-level prefetching (see Router × Query Integration section)
Infinite Queries - Infinite scrolling/pagination:
function Projects() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // Required in v5
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, // Show previous data while fetching
})
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 }) {
// First query
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
})
// Second query waits for first
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user.id),
enabled: !!user?.id, // Only runs when user.id exists
})
return <div>{/* render projects */}</div>
}
staleTime is your primary control - adjust this, not gcTime:
// Real-time data (default)
staleTime: 0 // Always considered stale, refetch on mount
// User profiles (changes infrequently)
staleTime: 1000 * 60 * 2 // Fresh for 2 minutes
// Static reference data
staleTime: 1000 * 60 * 10 // Fresh for 10 minutes
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:
// ❌ Waterfall - each query waits for previous
function Dashboard() {
const { data: user } = useQuery(userQuery)
const { data: posts } = useQuery(postsQuery(user?.id))
const { data: stats } = useQuery(statsQuery(user?.id))
}
// ✅ Parallel - all queries start simultaneously
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,
})
}
// ✅ Best - prefetch in route loader (see Router × Query Integration)
Never copy server state to local state - this opts out of background updates:
// ❌ Wrong - copies to state, loses reactivity
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
const [todos, setTodos] = useState(data)
// ✅ Correct - use query data directly
const { data: todos } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
MSW is the recommended approach - mock the network layer:
// src/test/mocks/handlers.ts
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 })
}),
]
// src/test/setup.ts
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:
// src/test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Prevent retries in tests
gcTime: Infinity,
},
},
})
}
export function renderWithClient(ui: React.ReactElement) {
const testQueryClient = createTestQueryClient()
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
)
}
Test queries:
import { renderWithClient } from '@/test/utils'
import { screen } from '@testing-library/react'
test('displays todos', async () => {
renderWithClient(<TaskList />)
// Wait for data to load
expect(await screen.findByText('Test todo')).toBeInTheDocument()
})
test('shows error state', async () => {
// Override handler for this test
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:
retry: false to prevent timeoutsfindBy*) for data that loads❌ Don't store query data in Redux/Context:
❌ Don't call refetch() with different parameters:
// ❌ Wrong - breaks declarative pattern
const { data, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetchTodos(filters),
})
// Later: refetch with different filters??? Won't work!
// ✅ Correct - include params in key
const [filters, setFilters] = useState('all')
const { data } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
})
// Changing filters automatically refetches
❌ Don't use queries for local state:
❌ Don't create QueryClient inside components:
// ❌ Wrong - new cache every render
function App() {
const client = new QueryClient()
return <QueryClientProvider client={client}>...</QueryClientProvider>
}
// ✅ Correct - stable instance
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)
staleTime - How long data is considered fresh:
0 (default) - Always stale, refetch on mount/focus30_000 (30s) - Good for user-generated content120_000 (2min) - Good for profile data600_000 (10min) - Good for static reference datagcTime (formerly cacheTime) - How long unused data stays in cache:
300_000 (5min, default) - Good for most casesInfinity - Keep forever (useful with persistence)0 - Immediate garbage collection (not recommended)Relationship: staleTime controls refetch frequency, gcTime controls memory cleanup.
Seamlessly integrate TanStack Router with TanStack Query for optimal SPA performance and instant navigation.
The key pattern: Use route loaders to prefetch queries BEFORE navigation completes.
Benefits:
// src/routes/users/$id.tsx
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, // Fresh for 30 seconds
})
},
component: UserPage,
})
function UserPage() {
const { id } = Route.useParams()
const { data: user } = useQuery({
queryKey: usersKeys.detail(id),
queryFn: () => fetchUser(id),
})
// Data is already loaded from loader, so this returns instantly
return <div>{user.name}</div>
}
Query Options provide maximum type safety and DRY:
// features/users/queries.ts
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))
}
// src/routes/users/$userId.tsx
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>
}
export const Route = createFileRoute('/dashboard')({
loader: async () => {
// Run in parallel
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()
// All data pre-loaded, renders instantly
return (
<div>
<UserHeader user={user} />
<StatsPanel stats={stats} />
<PostsList posts={posts} />
</div>
)
}
export const Route = createFileRoute('/users/$userId/posts')({
loader: async ({ params }) => {
// First ensure user data
const user = await queryClient.ensureQueryData(
userQueryOptions(params.userId)
)
// Then fetch user's posts
return queryClient.ensureQueryData(
userPostsQueryOptions(user.id)
)
},
component: UserPostsPage,
})
prefetchQuery - Fire and forget, don't wait:
loader: ({ params }) => {
// Don't await - just start fetching
queryClient.prefetchQuery(userQueryOptions(params.userId))
// Navigation continues immediately
}
ensureQueryData - Wait for data (recommended):
loader: async ({ params }) => {
// Await - navigation waits until data is ready
return await queryClient.ensureQueryData(userQueryOptions(params.userId))
}
fetchQuery - Always fetches fresh:
loader: async ({ params }) => {
// Ignores cache, always fetches
return await queryClient.fetchQuery(userQueryOptions(params.userId))
}
Recommendation: Use ensureQueryData for most cases - respects cache and staleTime.
import { Link } from '@tanstack/react-router'
<Link
to="/users/$userId"
params={{ userId: '123' }}
preload="intent" // Preload on hover/focus
>
View User
</Link>
// src/routes/users/index.tsx
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} />
}