Home JavaScript TanStack Query (React Query)
Advanced 8 min · July 13, 2026

TanStack Query (React Query)

Server state management, queries, mutations, caching, stale-while-revalidate, and devtools..

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 13, 2026
last updated
2,018
articles · all by Naren
Before you start⏱ 33 min read
  • React 18+, Node.js 18+, npm/yarn/pnpm, basic understanding of hooks (useState, useEffect), familiarity with REST APIs and async/await, TypeScript recommended.
Quick Answer

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.

✦ Definition~90s read
What is TanStack Query (React Query)?

TanStack Query (React Query) is a fundamental concept in React development. It refers to the patterns, APIs, and best practices that React provides for building user interfaces. Understanding this concept is essential for writing clean, efficient, and maintainable React code. This tutorial covers everything from basic usage to advanced patterns with real-world examples.

React is a JavaScript library for building user interfaces.
Plain-English First

React is a JavaScript library for building user interfaces. This article covers query — a key concept for building modern web applications.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

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.

useProducts.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
import { useQuery } from '@tanstack/react-query';
import { getProducts } from './api';

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: getProducts,
    staleTime: 30 * 1000, // 30 seconds
    gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
    refetchOnWindowFocus: true,
  });
}
Output
// On mount: fetches products, caches for 30s stale, 5min gc
// After 30s: background refetch on window focus
// After 5min of no usage: cache evicted
Try it live
⚠ Don't Set staleTime to Infinity
Setting staleTime to Infinity disables background refetches, turning TanStack Query into a simple cache. This defeats its purpose. Only do this for truly immutable data (e.g., static config).
📊 Production Insight
In a production e-commerce app, we had staleTime=0 for product availability. This caused a refetch on every mount, but with query deduplication, concurrent requests were merged into one—saving bandwidth and preventing race conditions.
🎯 Key Takeaway
Server state is inherently stale; TanStack Query's stale-while-revalidate pattern ensures freshness without blocking the UI.
react-query THECODEFORGE.IO Background Refetching and Stale Time Tuning Step-by-step process of automatic data refresh in TanStack Query Initial Fetch Query executes on mount or hook call Stale Time Elapsed Configurable duration before data considered stale Background Refetch Trigger Window refocus, network reconnect, or interval Cache Update New data replaces stale cache entry silently UI Re-render Components using query key update automatically ⚠ Setting staleTime too low causes excessive refetches Tune based on data volatility; use 30s for dynamic data THECODEFORGE.IO
thecodeforge.io
React Query

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.

useUser.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
import { useQuery } from '@tanstack/react-query';
import { getUser } from './api';

export function useUser(userId: string | undefined) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId!),
    enabled: !!userId, // query won't run if userId is undefined
  });
}
Output
// userId = 'abc' -> fetches /users/abc, caches under ['user','abc']
// userId = undefined -> query is disabled, no fetch
// Switching userId -> new fetch, old cache preserved
Try it live
💡Use Query Key Factories
Define a factory object to generate keys consistently: const userKeys = { all: ['users'] as const, detail: (id: string) => ['users', id] as const }; This prevents typos and makes cache invalidation easier.
📊 Production Insight
We once had a bug where clearing a filter didn't refetch because the query key didn't include the filter. Adding the filter to the key fixed it, but we also had to invalidate the old cache to avoid showing stale data.
🎯 Key Takeaway
Query keys must include all dependencies; use factories to keep them consistent and type-safe.

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'].

useAddTodo.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from './api';

export function useAddTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: addTodo,
    onMutate: async (newTodo) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const previousTodos = queryClient.getQueryData(['todos']);
      queryClient.setQueryData(['todos'], (old: any) => [...old, newTodo]);
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });
}
Output
// On mutate: optimistically adds todo to cache
// On error: rolls back to previous todos
// On settle: invalidates ['todos'] to refetch from server
Try it live
⚠ Optimistic Updates Require Rollback Logic
Always save the previous state in onMutate and restore it in onError. Without rollback, a failed mutation leaves the UI in a broken state.
📊 Production Insight
In a social media app, we used optimistic updates for likes. The mutation would instantly increment the like count, then invalidate the post query. If the server rejected the like (e.g., user already liked), we rolled back. This made the UI feel instant.
🎯 Key Takeaway
Mutations should optimistically update the cache and invalidate only the affected queries to avoid refetch storms.
react-query THECODEFORGE.IO TanStack Query Cache Architecture Layered structure of query key management and cache hierarchy UI Layer useQuery Hook | useMutation Hook | QueryClient Provider Query Key Layer String Keys | Array Keys | Hash Keys Cache Store QueryCache | MutationCache | Stale Time Config Background Sync Layer Refetch Logic | Window Focus Listener | Network Status Persistence Layer PersistQueryClient | Local Storage Adapter | Async Storage THECODEFORGE.IO
thecodeforge.io
React Query

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.

useStockPrice.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
import { useQuery } from '@tanstack/react-query';
import { getStockPrice } from './api';

export function useStockPrice(symbol: string) {
  return useQuery({
    queryKey: ['stock', symbol],
    queryFn: () => getStockPrice(symbol),
    staleTime: 0, // always stale, refetch on mount/focus
    refetchInterval: 10_000, // poll every 10 seconds
    refetchIntervalInBackground: false, // stop polling when tab hidden
  });
}
Output
// On mount: fetches immediately
// Every 10s: background refetch (only if tab is active)
// On window focus: refetches (since staleTime=0)
Try it live
🔥refetchInterval vs staleTime
refetchInterval forces periodic refetches regardless of staleTime. Use it for real-time data. staleTime only affects whether a refetch is triggered by events like mount or focus.
📊 Production Insight
We had a dashboard that polled every 5 seconds with staleTime=0. On a slow network, the UI would flash between old and new data. We increased staleTime to 2 seconds to avoid showing stale data while the refetch was in flight, and used placeholderData to keep the previous data smooth.
🎯 Key Takeaway
Tune staleTime and refetchInterval based on data volatility; avoid polling in background to save resources.

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.

useUserWithPosts.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useQuery } from '@tanstack/react-query';
import { getUser, getPostsByUser } from './api';

export function useUserWithPosts(userId: string) {
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
  });

  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => getPostsByUser(userId),
    enabled: !!userQuery.data, // wait until user is fetched
  });

  return { user: userQuery.data, posts: postsQuery.data, isLoading: userQuery.isLoading || postsQuery.isLoading };
}
Output
// userQuery fetches immediately
// postsQuery waits until userQuery.data is truthy
// If userQuery fails, postsQuery never runs
Try it live
💡Avoid Waterfall with Parallel Queries
If queries are independent, don't make them dependent. Use parallel queries to fetch simultaneously. Only use dependent queries when the second query requires data from the first.
📊 Production Insight
We once had a bug where a dependent query was enabled even when the dependency was undefined, causing a request with missing parameters. Adding enabled: !!dependency.data fixed it, but we also added error handling to prevent the dependent query from running if the dependency failed.
🎯 Key Takeaway
Use enabled for dependent queries to avoid manual promise chaining; parallel queries run concurrently by default.

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.

useInfiniteFeed.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
import { useInfiniteQuery } from '@tanstack/react-query';
import { getFeed } from './api';

export function useInfiniteFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam = 0 }) => getFeed({ cursor: pageParam }),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    staleTime: 60_000,
  });
}
Output
// Initial fetch: pageParam=0, returns data and nextCursor
// fetchNextPage() called: pageParam=nextCursor, appends to pages
// When nextCursor is null, hasNextPage becomes false
Try it live
🔥keepPreviousData vs placeholderData
keepPreviousData (deprecated) shows old data during fetch. Use placeholderData: (previousData) => previousData to achieve the same effect with more control.
📊 Production Insight
We had an infinite scroll feed that refetched from page 1 every time the component mounted. Setting staleTime to 5 minutes prevented this, but we also had to invalidate the feed query when the user posted new content to show it at the top.
🎯 Key Takeaway
Use useInfiniteQuery for cursor-based pagination; set staleTime to avoid refetching the entire list on mount.

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.

invalidationExample.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

// Invalidate all todo queries
queryClient.invalidateQueries({ queryKey: ['todos'] });

// Invalidate only a specific todo
queryClient.invalidateQueries({ queryKey: ['todos', todoId] });

// Invalidate with predicate (e.g., all queries that start with 'todos')
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'todos',
});

// Refetch only active queries
queryClient.invalidateQueries({ queryKey: ['todos'], refetchType: 'active' });
Output
// All three methods mark queries as stale and trigger refetch if active
Try it live
⚠ Avoid Invalidating All Queries
Calling invalidateQueries() without a key invalidates every cache entry. This is almost never what you want. Always specify a key or predicate.
📊 Production Insight
We had a bug where invalidating ['todos'] also invalidated ['todos', 'stats'] because the key started with 'todos'. We switched to exact: true to avoid this.
🎯 Key Takeaway
Targeted invalidation reduces unnecessary refetches; use exact keys or predicates for precision.

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.

useQueryWithRetry.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { useQuery } from '@tanstack/react-query';
import { getUser } from './api';

export function useUserWithRetry(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
    retry: (failureCount, error: any) => {
      if (error.status === 404) return false; // don't retry on 404
      return failureCount < 3;
    },
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), // exponential backoff capped at 10s
  });
}
Output
// On 404: no retry, query goes to error state
// On 500: retries up to 3 times with exponential backoff
Try it live
🔥Global Retry Configuration
Set default retry behavior in QueryClient: new QueryClient({ defaultOptions: { queries: { retry: 2, retryDelay: 1000 } } }). Override per query as needed.
📊 Production Insight
We had a query that fetched user permissions. A 403 error meant the user didn't have access; retrying would never succeed. We set retry: false and showed an access denied message immediately.
🎯 Key Takeaway
Customize retry logic per query; avoid retrying on client errors like 4xx.

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.

App.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
Output
// In development: devtools panel appears in bottom right
// In production: ReactQueryDevtools is automatically excluded if tree-shaken
Try it live
💡Use Devtools to Debug Stale Data
If you see stale data, open devtools and check the query's staleTime and last updated timestamp. You can manually refetch to verify the fix.
📊 Production Insight
We once had a bug where data wasn't updating after a mutation. Using devtools, we saw that the mutation succeeded but the query key didn't match the invalidation key. Fixing the key resolved the issue.
🎯 Key Takeaway
Devtools are essential for debugging cache behavior; include them in development only.

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%.

useUserName.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
import { useQuery } from '@tanstack/react-query';
import { getUser } from './api';

export function useUserName(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => getUser(userId),
    select: (user) => user.name, // only subscribe to name changes
  });
}
Output
// Component re-renders only when user.name changes
// Other user fields can update without causing re-render
Try it live
🔥Structural Sharing Can Mask Bugs
If your API returns new object references each time, structural sharing keeps the old reference if data is deeply equal. This can hide mutation bugs where you expect a new object. Use it wisely.
📊 Production Insight
We had a dashboard with 50 widgets, each using a query. Without select, a background refetch of the base data caused all widgets to re-render. Adding select to each widget reduced re-renders to only the affected widgets.
🎯 Key Takeaway
Use select to minimize re-renders; rely on structural sharing for performance with immutable data.

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.

useProducts.test.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useProducts } from './useProducts';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }, // disable retries for tests
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

it('fetches products', async () => {
  const { result } = renderHook(() => useProducts(), { wrapper: createWrapper() });

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => expect(result.current.isSuccess).toBe(true));

  expect(result.current.data).toEqual([{ id: 1, name: 'Product A' }]);
});
Output
// Test passes if useProducts returns data after fetch
Try it live
⚠ Disable Retries in Tests
Set retry: false in test QueryClient to avoid timeouts. Otherwise, failed queries will retry and cause test flakiness.
📊 Production Insight
We had a test that passed locally but failed in CI because of network latency. We added a mock that resolved immediately and set retry: false. This made tests deterministic.
🎯 Key Takeaway
Wrap components in a fresh QueryClientProvider for tests; disable retries to avoid timeouts.

TanStack Query v5 Breaking Changes

TanStack Query v5 introduces several breaking changes that require attention when upgrading from v4. Key changes include:

  1. Removal of keepPreviousData: Replaced by placeholderData with keepPreviousData function.
  2. Query Key Structure: Query keys must be arrays; string-only keys are no longer supported.
  3. onSuccess, onError, onSettled Deprecated: These callbacks are removed from useQuery and useMutation. Use queryClient's event handlers or side effects in components.
  4. useMutation Return Value: mutate no longer returns a promise; use mutateAsync for promise-based usage.
  5. cacheTime Renamed to gcTime: The option cacheTime is now gcTime (garbage collection time).
  6. useQueries Syntax: The queries option now requires an array of query objects directly, not a function returning an array.
  7. 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.

migration-example.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// v4
const query = useQuery('todos', fetchTodos, {
  keepPreviousData: true,
  onSuccess: (data) => console.log(data),
})

// v5
const query = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  placeholderData: keepPreviousData,
})

// v4 mutation
const mutation = useMutation(updateTodo, {
  onSuccess: () => queryClient.invalidateQueries('todos'),
})
mutation.mutate(newTodo)

// v5 mutation
const mutation = useMutation({
  mutationFn: updateTodo,
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
mutation.mutateAsync(newTodo).then(() => console.log('done'))
Try it live
⚠ Breaking Changes Require Careful Migration
📊 Production Insight
In production, run the migration in a feature branch and use TypeScript strict mode to identify breaking changes. Consider using a codemod tool if available to automate parts of the migration.
🎯 Key Takeaway
TanStack Query v5 removes deprecated callbacks, enforces array query keys, and renames 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.

Benefits
  • Simplifies component code by removing manual loading checks.
  • Enables concurrent rendering and streaming SSR.
  • Works seamlessly with React's Suspense and ErrorBoundary.

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.

Caveats
  • useSuspenseQuery cannot be used with enabled; use conditional rendering or separate components.
  • It's best for data required for rendering; for optional data, stick with useQuery.
suspense-example.tsxTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense, ErrorBoundary } from 'react'

function UserProfile({ userId }: { userId: string }) {
  const { data } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
  return <div>{data.name}</div>
}

export default function App() {
  return (
    <ErrorBoundary fallback={<div>Error loading user</div>}>
      <Suspense fallback={<div>Loading user...</div>}>
        <UserProfile userId="1" />
      </Suspense>
    </ErrorBoundary>
  )
}
Try it live
🔥Suspense Requires a Concurrent Root
📊 Production Insight
Use 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.
🎯 Key Takeaway
useSuspenseQuery eliminates manual loading states by leveraging React Suspense, leading to cleaner components and better UX with concurrent features.
Server State vs Client State Management Contrasting approaches for data handling in React applications TanStack Query (Server State) Redux/Context (Client State) Data Source Async server API calls Local store or context Cache Invalidation Automatic via staleTime and refetch Manual dispatch actions Background Sync Built-in refetch on focus/reconnect Requires custom middleware Error Handling Retry logic with exponential backoff Manual try-catch in thunks Pagination Support useInfiniteQuery with cursor/page params Custom reducer logic THECODEFORGE.IO
thecodeforge.io
React Query

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.

Benefits
  • 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.

Best Practices
  • Keep factories close to related API functions.
  • Use as const to infer literal types.
  • Export factories for reuse across components.
queryKeyFactory.tsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { createQueryKeyStore } from '@lukemorales/query-key-factory'

export const todoKeys = createQueryKeyStore({
  todos: {
    all: null,
    list: (filters: { status?: string }) => [filters],
    detail: (id: number) => [id],
  },
})

// Usage
const { data } = useQuery({
  queryKey: todoKeys.todos.list({ status: 'done' }),
  queryFn: () => fetchTodos({ status: 'done' }),
})

queryClient.invalidateQueries({ queryKey: todoKeys.todos.all })
Try it live
💡Type Safety Prevents Bugs
📊 Production Insight
Adopt the factory pattern early in your project. Use a library like @lukemorales/query-key-factory for convenience. Ensure all team members follow the pattern to avoid inconsistent keys.
🎯 Key Takeaway
Query Key Factory pattern centralizes and type-safes query key definitions, reducing errors and improving maintainability in TanStack Query applications.
⚙ Quick Reference
14 commands from this guide
FileCommand / CodePurpose
useProducts.tsexport function useProducts() {Why Server State Is Not Client State
useUser.tsexport function useUser(userId: string | undefined) {Query Keys
useAddTodo.tsexport function useAddTodo() {Mutations
useStockPrice.tsexport function useStockPrice(symbol: string) {Background Refetching and Stale Time Tuning
useUserWithPosts.tsexport function useUserWithPosts(userId: string) {Parallel and Dependent Queries
useInfiniteFeed.tsexport function useInfiniteFeed() {Paginated and Infinite Queries
invalidationExample.tsconst queryClient = useQueryClient();Cache Invalidation Strategies
useQueryWithRetry.tsexport function useUserWithRetry(userId: string) {Error Handling and Retry Logic
App.tsxconst queryClient = new QueryClient();Devtools
useUserName.tsexport function useUserName(userId: string) {Performance Optimization
useProducts.test.tsconst createWrapper = () => {Testing Queries and Mutations
migration-example.tsxconst query = useQuery('todos', fetchTodos, {TanStack Query v5 Breaking Changes
suspense-example.tsxfunction UserProfile({ userId }: { userId: string }) {useSuspenseQuery for Suspense-first Data Fetching
queryKeyFactory.tsexport const todoKeys = createQueryKeyStore({Query Key Factory Pattern

Key takeaways

1
Server state is not client state
Treat API responses as stale by default; use TanStack Query's stale-while-revalidate pattern to keep data fresh without blocking the UI.
2
Query keys are cache addresses
Include all dependencies in query keys; use factories to maintain consistency and simplify invalidation.
3
Mutations need optimistic updates
Use onMutate to update the cache instantly, onError to roll back, and onSettled to invalidate only affected queries.
4
Performance requires selectivity
Use the select option to minimize re-renders and rely on structural sharing for efficient cache comparisons.

Common mistakes to avoid

3 patterns
×

Not understanding React component re-rendering

Fix
Use React.memo, useMemo, and useCallback strategically to prevent unnecessary re-renders.
×

Ignoring the rules of hooks

Fix
Always call hooks at the top level, not inside conditions, loops, or callbacks.
×

Mutating state directly instead of using setState

Fix
Always use the state setter function and treat state as immutable.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the Virtual DOM and how does React use it?
Q02JUNIOR
Explain the difference between state and props.
Q03JUNIOR
What is the purpose of the useEffect hook?
Q04JUNIOR
How does React handle keys in lists?
Q01 of 04JUNIOR

What is the Virtual DOM and how does React use it?

ANSWER
The Virtual DOM is a lightweight JavaScript representation of the real DOM. React diffs the Virtual DOM with the previous version and applies only the changed parts to the real DOM.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the difference between staleTime and gcTime?
02
How do I refetch a query manually?
03
Can I use TanStack Query with GraphQL?
04
How do I handle optimistic updates with mutations?
05
What is the best way to structure query keys?
06
How do I test components that use TanStack Query?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
July 13, 2026
last updated
2,018
articles · all by Naren
🔥

That's React. Mark it forged?

8 min read · try the examples if you haven't

Previous
Zustand — Modern State Management
39 / 40 · React
Next
State Management Decision Guide