TanStack Query (React Query)
Server state management, queries, mutations, caching, stale-while-revalidate, and devtools..
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- ✓React 18+, Node.js 18+, npm/yarn/pnpm, basic understanding of hooks (useState, useEffect), familiarity with REST APIs and async/await, TypeScript recommended.
TanStack Query (React Query): A core React concept for building modern user interfaces. It helps you structure your components efficiently and handle data flow predictably.
React is a JavaScript library for building user interfaces. This article covers query — a key concept for building modern web applications.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
You've been fetching data wrong. I've seen production apps crash because developers treated server state like client state, caching blindly and invalidating never. TanStack Query (React Query) isn't just another data-fetching library—it's a paradigm shift that forces you to think in terms of stale-while-revalidate, background refetching, and optimistic updates. If you're still managing loading states with useState and useEffect, your codebase is already rotting. This is the blunt truth: without a dedicated server state manager, you're writing buggy, slow, and unmaintainable code. Let's fix that.
Why Server State Is Not Client State
Most developers treat API responses like local variables: fetch once, store in state, and forget. This works until the data changes on the server, the user navigates away and back, or another tab mutates the same resource. Server state is asynchronous, shared, and potentially stale. TanStack Query acknowledges this by design: it caches data but marks it as stale immediately, triggering background refetches when components remount or windows regain focus. This prevents the dreaded 'stale data' bug that plagues naive fetch-on-mount patterns. In production, I've seen apps display outdated inventory counts because they cached API responses indefinitely. With TanStack Query, you define a staleTime that matches your business requirements—for example, 30 seconds for a dashboard, 5 minutes for user profiles. The library handles the rest, including deduplication of concurrent requests and automatic garbage collection of unused caches.
Query Keys: The Foundation of Cache Management
Query keys are not just identifiers—they are the cache's address system. A poorly designed key leads to cache collisions, stale data, or unnecessary refetches. Keys can be strings or arrays of any serializable values. The rule: include all variables that affect the query result. For example, a paginated list should include the page number and filters in the key. If you omit a filter, changing it won't trigger a new fetch—you'll get the cached result from the previous filter. In production, I've debugged a bug where switching between user profiles showed the same data because the query key was just ['user']. The fix: ['user', userId]. TanStack Query also supports dependent queries: if a key includes a variable that is null, the query is disabled until that variable is defined. This eliminates the need for manual enabled checks.
Mutations: The Other Half of the Equation
Fetching data is only half the story. Mutations (POST, PUT, DELETE) are where TanStack Query shines with optimistic updates and automatic cache invalidation. A mutation is a side effect that changes server state. After a mutation succeeds, you typically want to invalidate related queries so they refetch. But naive invalidation can cause a waterfall of requests. The better approach: use onMutate to optimistically update the cache, then onSettled to invalidate. This gives instant UI feedback while keeping the server as the source of truth. In production, I've seen apps that invalidate all queries on any mutation—this causes massive refetch storms. Instead, invalidate only the affected query keys. For example, after adding a new todo, invalidate ['todos'] but not ['users'].
Background Refetching and Stale Time Tuning
TanStack Query's default behavior is to refetch on mount, window focus, and network reconnection. This is great for freshness but can be overkill. The staleTime option controls how long data is considered fresh. During the stale time, the cache is used without refetching. After stale time expires, data is still shown but a background refetch is triggered. Tuning staleTime is critical for performance. For example, a list of countries rarely changes—set staleTime to 24 hours. A stock ticker needs staleTime of 0. In production, we set staleTime to 5 minutes for user profiles, but 30 seconds for a dashboard. Also, use refetchInterval for polling: set it to 10 seconds for a live feed. But beware: polling can cause high server load. Combine with refetchIntervalInBackground to stop polling when the tab is hidden.
Parallel and Dependent Queries
Real apps often need to fetch multiple resources in parallel or wait for one query to finish before starting another. TanStack Query handles both elegantly. For parallel queries, simply call useQuery multiple times—they will run concurrently. For dependent queries, use the enabled option to disable a query until its dependency is available. This is cleaner than chaining promises. In production, we had a user profile page that needed user data first, then their posts. We used enabled: !!user to defer the posts query until user was defined. This also automatically cancels the dependent query if the dependency changes. For truly sequential logic, you can use the result of one query as input to another, but be careful: if the dependency query fails, the dependent query won't run.
Paginated and Infinite Queries
Handling pagination manually is error-prone. TanStack Query provides useQuery for offset-based pagination and useInfiniteQuery for cursor-based or infinite scroll. With useQuery, you include the page number in the query key and use keepPreviousData to show the previous page's data while fetching the next. This prevents layout shifts. For infinite queries, useInfiniteQuery manages page parameters and provides fetchNextPage and hasNextPage. In production, we used useInfiniteQuery for a feed with cursor-based pagination. The key insight: set staleTime to a reasonable value so that revisiting a page doesn't refetch from scratch. Also, use getNextPageParam to extract the cursor from the response. If the API returns a nextCursor, return it; otherwise return undefined to signal end of list.
Cache Invalidation Strategies
Invalidation is how you tell TanStack Query that cached data is no longer valid. The simplest way is queryClient.invalidateQueries({ queryKey: ['todos'] }). This marks the query as stale and triggers a refetch if the query is currently active. But aggressive invalidation can cause unnecessary refetches. A better strategy: invalidate only when a mutation succeeds, and use exact matching or predicate functions to target specific queries. For example, after updating a todo, invalidate only ['todos', todoId] instead of all todos. Also, consider using refetchType: 'active' to only refetch queries that are currently being used. In production, we had a dashboard with multiple widgets; invalidating all queries on any mutation caused a spike in API calls. We switched to targeted invalidation and saw a 50% reduction in requests.
Error Handling and Retry Logic
Network requests fail. TanStack Query provides built-in retry logic with exponential backoff. By default, it retries 3 times with a delay that doubles each time. You can customize retry count, delay, and conditions. For example, don't retry on 4xx errors (client errors) because they are unlikely to succeed. Use retry: (failureCount, error) => error.status !== 404 to skip retries for not found. Also, use onError callback to show toast notifications or log errors. In production, we had a mutation that failed with a 409 conflict; retrying would never succeed, so we set retry: false for that mutation. For queries, we set retryDelay to a fixed 1 second for time-sensitive data. Remember: retries happen in the background; the UI still shows the previous data until a successful fetch.
Devtools: Debugging Cache State
TanStack Query Devtools are a browser extension that shows the cache state, query status, and allows manual invalidation. It's invaluable for debugging. You can see which queries are stale, fetching, or inactive. You can also trigger refetches or remove queries from the cache. In production, we use it to verify that cache invalidation works correctly. The devtools are only included in development builds by default. To enable them, add <ReactQueryDevtools /> to your app. They are tree-shaken in production. One tip: use the devtools to inspect query keys and ensure they match your expectations. I've caught many bugs where a query key was missing a parameter, causing cache collisions.
Performance Optimization: Selectors and Structural Sharing
TanStack Query re-renders components only when the selected data changes. Use the select option to transform or pick a subset of data. This prevents unnecessary re-renders when other parts of the cache update. For example, if you only need a user's name, select: (user) => user.name. The component will only re-render when the name changes, not when other user fields update. Additionally, TanStack Query uses structural sharing by default: it compares the previous and new data deeply and returns the same reference if nothing changed. This is a huge performance win for immutable data. In production, we had a component that rendered a list of items; without select, it re-rendered on every background refetch even if the list was identical. Adding select to pick only the needed fields reduced re-renders by 90%.
Testing Queries and Mutations
Testing TanStack Query code requires mocking the query client and network. Use @tanstack/react-query's QueryClientProvider with a fresh QueryClient in tests. For network mocking, use MSW (Mock Service Worker) or simple jest mocks. The key is to wrap your component in a test QueryClientProvider and use waitFor to assert on loading and success states. For mutations, use renderHook with useMutation and assert on mutateAsync. In production, we write integration tests that verify cache invalidation: after a mutation, the related query should refetch. We also test error states by making the mock reject. One common pitfall: forgetting to reset the query client between tests, causing cached data to leak. Always create a new QueryClient for each test.
TanStack Query v5 Breaking Changes
TanStack Query v5 introduces several breaking changes that require attention when upgrading from v4. Key changes include:
- Removal of
keepPreviousData: Replaced byplaceholderDatawithkeepPreviousDatafunction. - Query Key Structure: Query keys must be arrays; string-only keys are no longer supported.
onSuccess,onError,onSettledDeprecated: These callbacks are removed fromuseQueryanduseMutation. UsequeryClient's event handlers or side effects in components.useMutationReturn Value:mutateno longer returns a promise; usemutateAsyncfor promise-based usage.cacheTimeRenamed togcTime: The optioncacheTimeis nowgcTime(garbage collection time).useQueriesSyntax: Thequeriesoption now requires an array of query objects directly, not a function returning an array.- TypeScript Improvements: Better type inference for query keys and data.
To migrate, update all query keys to arrays, replace keepPreviousData with placeholderData: keepPreviousData, and move side effects from callbacks to useEffect or queryClient observers. For mutations, switch to mutateAsync if you need promises. Rename cacheTime to gcTime. Review the official migration guide for a complete list.
cacheTime to gcTime. Migrate systematically using the official guide.useSuspenseQuery for Suspense-first Data Fetching
useSuspenseQuery is a new hook in TanStack Query v5 that integrates with React's Suspense for a more declarative data fetching pattern. Unlike useQuery, which returns loading states, useSuspenseQuery throws a promise during data fetching, allowing Suspense boundaries to handle loading states automatically.
- Simplifies component code by removing manual loading checks.
- Enables concurrent rendering and streaming SSR.
- Works seamlessly with React's
SuspenseandErrorBoundary.
Usage: Wrap your component in a <Suspense> boundary. Inside, use useSuspenseQuery with the same options as useQuery (except enabled and placeholderData are not supported). The hook returns data directly (no isLoading).
Example: ```tsx function TodoList() { const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos, }) return <ul>{data.map(todo => <li key={todo.id}>{todo.title}</li>)}</ul> }
function App() { return ( <Suspense fallback={<div>Loading...</div>}> <TodoList /> </Suspense> ) } ```
Error Handling: Use an ErrorBoundary to catch errors thrown by useSuspenseQuery. Alternatively, use useSuspenseQuery's throwOnError option.
useSuspenseQuerycannot be used withenabled; use conditional rendering or separate components.- It's best for data required for rendering; for optional data, stick with
useQuery.
useSuspenseQuery for critical data that must be present before rendering. Combine with ErrorBoundary for robust error handling. Avoid using it for optional data to prevent unnecessary Suspense waterfalls.useSuspenseQuery eliminates manual loading states by leveraging React Suspense, leading to cleaner components and better UX with concurrent features.Query Key Factory Pattern
The Query Key Factory pattern provides a type-safe and maintainable way to manage query keys in TanStack Query. Instead of scattering string literals across your codebase, you define a factory object that generates query keys with proper typing.
- Centralized key management reduces typos and inconsistencies.
- TypeScript ensures type safety when accessing query data.
- Easy to refactor and extend.
Implementation: Create a factory object with methods that return query keys as arrays. Use as const or satisfy QueryKey type for strictness.
Example: ```tsx // queryKeyFactory.ts import { createQueryKeyStore } from '@lukemorales/query-key-factory'
// Or manual factory: export const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters: { status?: string }) => [...todoKeys.lists(), filters] as const, details: () => [...todoKeys.all, 'detail'] as const, detail: (id: number) => [...todoKeys.details(), id] as const, }
// Usage in hooks: function useTodos(filters: { status?: string }) { return useQuery({ queryKey: todoKeys.list(filters), queryFn: () => fetchTodos(filters), }) }
function useTodo(id: number) { return useQuery({ queryKey: todoKeys.detail(id), queryFn: () => fetchTodo(id), }) }
// Invalidation: queryClient.invalidateQueries({ queryKey: todoKeys.all }) queryClient.invalidateQueries({ queryKey: todoKeys.lists() }) ```
Library Support: The @lukemorales/query-key-factory library provides a createQueryKeyStore helper for even more concise factories.
- Keep factories close to related API functions.
- Use
as constto infer literal types. - Export factories for reuse across components.
@lukemorales/query-key-factory for convenience. Ensure all team members follow the pattern to avoid inconsistent keys.| File | Command / Code | Purpose |
|---|---|---|
| useProducts.ts | export function useProducts() { | Why Server State Is Not Client State |
| useUser.ts | export function useUser(userId: string | undefined) { | Query Keys |
| useAddTodo.ts | export function useAddTodo() { | Mutations |
| useStockPrice.ts | export function useStockPrice(symbol: string) { | Background Refetching and Stale Time Tuning |
| useUserWithPosts.ts | export function useUserWithPosts(userId: string) { | Parallel and Dependent Queries |
| useInfiniteFeed.ts | export function useInfiniteFeed() { | Paginated and Infinite Queries |
| invalidationExample.ts | const queryClient = useQueryClient(); | Cache Invalidation Strategies |
| useQueryWithRetry.ts | export function useUserWithRetry(userId: string) { | Error Handling and Retry Logic |
| App.tsx | const queryClient = new QueryClient(); | Devtools |
| useUserName.ts | export function useUserName(userId: string) { | Performance Optimization |
| useProducts.test.ts | const createWrapper = () => { | Testing Queries and Mutations |
| migration-example.tsx | const query = useQuery('todos', fetchTodos, { | TanStack Query v5 Breaking Changes |
| suspense-example.tsx | function UserProfile({ userId }: { userId: string }) { | useSuspenseQuery for Suspense-first Data Fetching |
| queryKeyFactory.ts | export const todoKeys = createQueryKeyStore({ | Query Key Factory Pattern |
Key takeaways
Common mistakes to avoid
3 patternsNot understanding React component re-rendering
Ignoring the rules of hooks
Mutating state directly instead of using setState
Interview Questions on This Topic
What is the Virtual DOM and how does React use it?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's React. Mark it forged?
8 min read · try the examples if you haven't