--- 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:** 1. **Single Object Signature**: All hooks now accept one configuration object: ```typescript // ✅ v5 - single object useQuery({ queryKey, queryFn, ...options }) // ❌ v4 - multiple overloads (deprecated) useQuery(queryKey, queryFn, options) ``` 2. **Renamed Options**: - `cacheTime` → `gcTime` (garbage collection time) - `keepPreviousData` → `placeholderData: keepPreviousData` - `isLoading` now means `isPending && isFetching` 3. **Callbacks Removed from useQuery**: - `onSuccess`, `onError`, `onSettled` removed from `useQuery` - Use global QueryCache callbacks instead - Prevents duplicate executions 4. **Infinite Queries Require initialPageParam**: - No default value provided - Must explicitly set `initialPageParam` (e.g., `0` or `null`) 5. **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: ```typescript { 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. ### Client Setup ```typescript // 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 ( {children} ) } ``` **DevTools Setup** (auto-excluded in production): ```typescript import { ReactQueryDevtools } from '@tanstack/react-query-devtools' {children} ``` ### Server-Side Rendering Configuration When using TanStack Query with SSR (Next.js, Remix, TanStack Start), configure server-specific defaults: ```typescript // 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. ### Streaming SSR (Experimental) For Next.js App Router, `@tanstack/react-query-next-experimental` enables streaming: ```bash pnpm add @tanstack/react-query-next-experimental ``` **Setup:** ```typescript // 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 ( {children} ) } ``` **Usage in Client Components:** ```typescript '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
{user.name}
} ``` **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: 1. **Client-Side Mutations** ```typescript // Server Component fetches, Client handles mutations export default async function PostPage({ params }) { const post = await fetchPost(params.id) // Server fetch return // Client mutations } 'use client' function PostWithComments({ post }) { const addComment = useMutation({ ... }) // Still need Query! // ... } ``` 2. **Background Refetching After Initial Load** ```typescript // Initial: Server Component renders with fresh data // After: Query keeps data fresh on client ``` 3. **Optimistic Updates** ```typescript // Can't do optimistic updates with Server Components alone const likeMutation = useMutation({ mutationFn: likePost, onMutate: async () => { // Optimistic update - only possible with Query }, }) ``` 4. **Real-Time Updates** ```typescript // WebSocket data, polling, etc. - client-only useQuery({ queryKey: ['notifications'], queryFn: fetchNotifications, refetchInterval: 30_000, // Real-time polling }) ``` #### Recommended Pattern ```typescript // Server Component: Initial fetch export default async function DashboardPage() { const initialData = await fetchDashboard() return ( ) } // 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({ ... }) // ... } ``` #### 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 ```typescript // 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 }) ``` #### 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: ```typescript // 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 => { 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**: - 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: ```typescript // ✅ 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: ```typescript // ✅ 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. ### Query Options API (Type Safety) **The modern pattern** for maximum type safety across your codebase: ```typescript 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**: - 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: ```typescript const fetchTodos = async (): Promise => { 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: ```typescript // 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: ```typescript // ✅ 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 }) ``` ### TypeScript Best Practices **Let TypeScript infer types** from queryFn rather than specifying generics: ```typescript // ✅ Recommended - inference const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, // Returns Promise }) // data is Todo[] | undefined // ❌ Unnecessary - explicit generics const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) ``` **Discriminated unions** automatically narrow types: ```typescript 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. ### Custom Hooks Pattern **Always create custom hooks** even for single queries: ```typescript // ✅ Recommended - custom hook with encapsulation export function usePost( id: number, options?: Omit, '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**: - Centralizes query logic - Easy to update all usages - Consistent configuration - Better testing ### Error Handling (Multi-Layer Strategy) **Layer 1: Component-Level** - Specific user feedback: ```typescript function TodoList() { const { data, error, isError, isLoading } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) if (isLoading) return if (isError) return {error.message} return
    {data.map(todo => )}
} ``` **Layer 2: Global Error Handling** - Background errors via QueryCache: ```typescript // 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: ```typescript import { QueryErrorResetBoundary } from '@tanstack/react-query' import { ErrorBoundary } from 'react-error-boundary' {({ reset }) => ( (

Error: {error.message}

)} >
)}
``` ### Suspense Integration **First-class Suspense support** in v5 with dedicated hooks: ```typescript import { useSuspenseQuery } from '@tanstack/react-query' function TodoList() { // data is NEVER undefined (type-safe) const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) return
    {data.map(todo => )}
} // Wrap with Suspense boundary function App() { return ( }> ) } ``` **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: ```typescript 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`: ```typescript const addTodoMutation = useMutation({ mutationFn: (newTodo: string) => axios.post('/api/todos', { text: newTodo }), onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }), }) const { isPending, variables, mutate } = addTodoMutation return (
    {todoQuery.data?.map(todo =>
  • {todo.text}
  • )} {isPending &&
  • {variables}
  • }
) ``` **Advanced optimistic updates** with rollback: ```typescript 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**: - 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): ```typescript // 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: ```typescript 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): ```typescript const logout = () => { removeAccessToken() queryClient.clear() // Clear all cached data navigate('/login') } ``` ### Advanced Patterns **Prefetching** - Eliminate loading states: ```typescript // Hover prefetching function ShowDetailsButton() { const queryClient = useQueryClient() const prefetch = () => { queryClient.prefetchQuery({ queryKey: ['details'], queryFn: getDetailsData, staleTime: 60_000, // Consider fresh for 1 minute }) } return ( ) } // Route-level prefetching (see Router × Query Integration section) ``` **Infinite Queries** - Infinite scrolling/pagination: ```typescript 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 return ( <> {data.pages.map((page, i) => ( {page.data.map(project => ( ))} ))} ) } ``` **Offset-Based Pagination** with `placeholderData`: ```typescript 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 => )} ) } ``` **Dependent Queries** - Sequential data fetching: ```typescript 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
{/* render projects */}
} ``` ### Performance Optimization **staleTime is your primary control** - adjust this, not `gcTime`: ```typescript // 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**: ```typescript // ❌ 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: ```typescript // ❌ 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 }) ``` ### Testing with Mock Service Worker (MSW) **MSW is the recommended approach** - mock the network layer: ```typescript // 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: ```typescript // 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( {ui} ) } ``` **Test queries**: ```typescript import { renderWithClient } from '@/test/utils' import { screen } from '@testing-library/react' test('displays todos', async () => { renderWithClient() // 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() 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**: ```typescript // ❌ 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**: - Query Cache expects refetchable data - Use useState/useReducer for client-only state **❌ Don't create QueryClient inside components**: ```typescript // ❌ Wrong - new cache every render function App() { const client = new QueryClient() return ... } // ✅ Correct - stable instance const queryClient = new QueryClient() function App() { return ... } ``` **❌ 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 ```typescript // 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
{user.name}
} ``` ### Using Query Options Pattern (Recommended) **Query Options** provide maximum type safety and DRY: ```typescript // 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
{user.name}
} ``` ### Multiple Queries in Loader ```typescript 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 (
) } ``` ### Dependent Queries in Loaders ```typescript 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, }) ``` ### Prefetch vs Ensure **`prefetchQuery`** - Fire and forget, don't wait: ```typescript loader: ({ params }) => { // Don't await - just start fetching queryClient.prefetchQuery(userQueryOptions(params.userId)) // Navigation continues immediately } ``` **`ensureQueryData`** - Wait for data (recommended): ```typescript loader: async ({ params }) => { // Await - navigation waits until data is ready return await queryClient.ensureQueryData(userQueryOptions(params.userId)) } ``` **`fetchQuery`** - Always fetches fresh: ```typescript loader: async ({ params }) => { // Ignores cache, always fetches return await queryClient.fetchQuery(userQueryOptions(params.userId)) } ``` **Recommendation:** Use `ensureQueryData` for most cases - respects cache and staleTime. ### Preloading on Link Hover ```typescript import { Link } from '@tanstack/react-router' View User ``` ### Search Params + Queries ```typescript // 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 } ``` --- ## 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