| name | tanstack-query |
| description | Comprehensive TanStack Query v5 patterns for async state management. Covers breaking changes, query key factories, data transformation, mutations, optimistic updates, authentication, testing with MSW, and anti-patterns. Use for all server state management, data fetching, and cache invalidation tasks. |
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 removed from 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 |
|---|
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.
Streaming SSR (Experimental)
For Next.js App Router, @tanstack/react-query-next-experimental enables streaming:
pnpm add @tanstack/react-query-next-experimental
Setup:
'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>
}
Benefits:
- Skip manual prefetching in Server Components
- Data streams to client as it resolves
- Suspense boundaries show loading states naturally
Limitations:
- 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,
})
}
Benefits:
- 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)
Benefits:
- 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 })
Benefits:
- 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>
)
}
Benefits:
- 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'] })
},
})
Key principles:
- 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>
)
}
Test queries:
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.
Benefits:
- 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