| |

React 63 ⚛️ TanStack Query Setup and Query Client

⏱️ Estimated read: 14 min | |

A React application that fetches data from a server needs more than fetch and useState. It needs caching, deduplication, background refetching, and a consistent way to handle loading and error states. TanStack Query is the library that provides all of these, and the QueryClient is the object that holds the cache and the configuration. The QueryClientProvider makes the client available to the entire component tree, and the useQuery hook is the entry point for every data-fetching component. Setting up the client correctly is the first step, and the defaults it carries determine how every query in the application behaves.

Key point: A QueryClient holds the cache for the entire application. In a client-only React app, create it once at the module scope. In an SSR framework like Next.js or TanStack Start, create it per request inside the render function, because a process-wide client would leak one user’s cached data into another user’s HTML .


Why TanStack Query exists

The server-state problem. React state is for UI state: a modal open, a tab active, a form value. Server state is different. It lives on a server, it changes without the application’s knowledge, and it must be kept in sync. The useEffect + useState pattern treats server state like UI state, which produces stale data, redundant requests, and loading spinners on every mount.

The cache problem. A component that fetches data on mount runs the fetch every time it mounts. Switching tabs, navigating away and back, or re-rendering the parent triggers the fetch again. TanStack Query caches the result by queryKey, so the second mount renders the cached data instantly and refetches in the background if the data is stale .

The deduplication problem. Two components that request the same data at the same time fire two network requests. TanStack Query deduplicates them: the second request waits for the first, and both components receive the same result.

The stale problem. Data is not always stale. A list of countries does not change; a stock price changes every second. The staleTime option tells the client how long the data is considered fresh. During the fresh window, the data is served from the cache without a background refetch .

The garbage collection problem. Cached data cannot live forever. The gcTime option (formerly cacheTime) determines how long inactive cached data stays in memory. After the window, the cache is garbage collected, and the next mount refetches from scratch .

The SSR problem. In a server-rendered application, the server can prefetch the data and pass the dehydrated cache to the client. The client hydrates the cache, and the first render uses the server data without a refetch. The setup differs from a client-only app, and the QueryClient must be created per request .


a. Creating the QueryClient

The QueryClient is the central object. It holds the cache, the default options, and the methods for invalidating and prefetching queries.

The basic creation

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient();

Without options, the client uses the aggressive but sane defaults: staleTime is 0, gcTime is 5 * 60 * 1000 (5 minutes), and the retry count is 3 .

The default options

The defaultOptions property sets the options for all queries and mutations. The queries object applies to the useQuery hook, and the mutations object applies to the useMutation hook .

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 10,
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
});

The staleTime option

The staleTime option sets how long the data is considered fresh. During the fresh window, the data is served from the cache without a background refetch. The default is 0, which means the data is immediately stale, and a background refetch runs on every mount, focus, and reconnect .

queries: {
  staleTime: 1000 * 60 * 5, // 5 minutes
}

The value is in milliseconds. The Infinity value means the data never becomes stale, and the 'static' value means the data is never refetched even when invalidated .

The gcTime option

The gcTime option (formerly cacheTime) sets how long inactive cached data stays in memory. The default is 5 minutes. After the window, the cache is garbage collected, and the next mount refetches .

queries: {
  gcTime: 1000 * 60 * 30, // 30 minutes
}

The retry option

The retry option sets the number of times a failed query is retried. The default is 3. The value can be a number or a function that returns a boolean .

queries: {
  retry: 1,
}

The refetch options

The refetchOnWindowFocus, refetchOnMount, and refetchOnReconnect options control when the stale data is refetched. The default is true for all three .

queries: {
  refetchOnWindowFocus: false,
  refetchOnReconnect: 'always',
}

The per-query override

Every option set in defaultOptions can be overridden on a per-query basis. The useQuery hook accepts the same options, and the hook’s values take precedence .

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 1000 * 60, // Overrides the default
});

b. Wrapping the application

The QueryClientProvider makes the client available to every component in the tree. Without it, the useQuery hook throws an error because it cannot find the client .

The client-only setup

In a client-only React app, the provider wraps the root component, and the client is created at the module scope.

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

The SSR setup

In an SSR framework, the client must be created per request, not at the module scope. The useState initializer or the framework’s per-request context ensures a fresh client for each user .

function QueryProvider({ children }) {
  const [queryClient] = useState(
    () => new QueryClient({
      defaultOptions: {
        queries: { staleTime: 60 * 1000 },
      },
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

The useState with the function initializer creates the client once per component instance, which in an SSR render is once per request .

The TanStack Start setup

TanStack Start creates the client inside the router’s getRouter function, which runs once per request. The setupRouterSsrQueryIntegration handles the dehydration and hydration .

export function getRouter() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: 30_000 } },
  });
  const router = createRouter({
    routeTree,
    context: { queryClient },
    defaultPreload: 'intent',
    defaultPreloadStaleTime: 0,
  });
  setupRouterSsrQueryIntegration({ router, queryClient });
  return router;
}

The DevTools

The ReactQueryDevtools component renders a panel that shows the cache, the queries, and their states. It is imported from @tanstack/react-query-devtools and placed inside the provider .

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  <YourApp />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

The DevTools are automatically excluded from production builds .


c. The first query

The useQuery hook is the entry point. It takes an object with a queryKey and a queryFn, and it returns the data, the loading state, and the error .

The basic query

import { useQuery } from '@tanstack/react-query';

function Example() {
  const { data, isPending, error } = useQuery({
    queryKey: ['repoData'],
    queryFn: async () => {
      const response = await fetch('https://api.github.com/repos/TanStack/query');
      if (!response.ok) throw new Error('Network response was not ok');
      return response.json();
    },
  });

  if (isPending) return <div>Loading...</div>;
  if (error) return <div>An error occurred: {error.message}</div>;

  return <h1>{data.full_name}</h1>;
}

The isPending flag is true when there is no data yet. The error is the error object if the fetch failed .

The query key

The queryKey is an array that uniquely identifies the query. The array form allows hierarchical keys and invalidation by prefix .

['todos']
['todos', todoId]
['users', userId, 'posts']

The key is serialized and used as the cache key. Two components with the same key share the same cache entry .

The query function

The queryFn is the function that fetches the data. It returns a promise, and the resolved value is the data. The function can be async or return a promise .

The v5 object syntax

TanStack Query v5 removed the function overloads. The object syntax is the only form. The v4 array syntax and the callback options are removed .

// ❌ v4 (removed in v5)
useQuery(['todos'], fetchTodos, { staleTime: 5000 });

// ✅ v5 (correct)
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
});

The isLoading change

The isLoading flag changed in v5. It now means isPending && isFetching, which is true only during the first fetch. The isPending flag is the correct one for the initial load state .

// v5
const { isPending, isLoading, isFetching } = useQuery({ ... });

if (isPending) return <div>Loading...</div>;

The query callbacks

The onSuccess, onError, and onSettled callbacks are removed from queries in v5. They still work on mutations. The useEffect is the alternative for the query side effects .


Complete Example Session

// ============================================
// PART 1: THE QUERY CLIENT
// ============================================

import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 10,
      retry: 2,
      refetchOnWindowFocus: false,
    },
  },
});

// ============================================
// PART 2: THE PROVIDER
// ============================================

import { QueryClientProvider } from '@tanstack/react-query';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
    </QueryClientProvider>
  );
}

// ============================================
// PART 3: THE FIRST QUERY
// ============================================

import { useQuery } from '@tanstack/react-query';

function Todos() {
  const { data, isPending, error } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const response = await fetch('/api/todos');
      if (!response.ok) throw new Error('Failed to fetch');
      return response.json();
    },
  });

  if (isPending) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return <ul>{data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;
}

// ============================================
// PART 4: THE V5 OBJECT SYNTAX
// ============================================

// v5 (correct)
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5000,
});

// ============================================
// PART 5: THE SSR PROVIDER
// ============================================

import { useState } from 'react';

function QueryProvider({ children }) {
  const [queryClient] = useState(
    () => new QueryClient({
      defaultOptions: {
        queries: { staleTime: 60 * 1000 },
      },
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

// ============================================
// PART 6: THE DEVTOOLS
// ============================================

import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

<QueryClientProvider client={queryClient}>
  <YourApp />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

// ============================================
// PART 7: THE DEFAULT OPTIONS
// ============================================

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 10,
      retry: 3,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
    },
    mutations: {
      retry: 0,
    },
  },
});

// ============================================
// PART 8: THE PER-QUERY OVERRIDE
// ============================================

useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 1000 * 30,
  enabled: !!userId,
});

// ============================================
// PART 9: THE SSR HYDRATION
// ============================================

import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

export async function getServerSideProps() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

function MyApp({ dehydratedState }) {
  return (
    <HydrationBoundary state={dehydratedState}>
      <YourApp />
    </HydrationBoundary>
  );
}

// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================

// 1. Create the QueryClient with the default options.
// 2. Wrap the app with QueryClientProvider.
// 3. Use useQuery with the object syntax.
// 4. The queryKey identifies the cache entry.
// 5. The queryFn fetches the data.
// 6. The isPending flag is the initial load state.
// 7. The DevTools show the cache in development.
// 8. In SSR, create the client per request.

The ten parts showed the query client, the provider, the first query, the v5 object syntax, the SSR provider, the DevTools, the default options, the per-query override, the SSR hydration, and the complete pattern.


Quick Reference

The QueryClient Options

OptionDefaultPurpose
staleTime0How long the data is fresh
gcTime5 * 60 * 1000How long inactive data stays in memory
retry3The number of retries
refetchOnWindowFocustrueRefetch when the window refocuses
refetchOnMounttrueRefetch when the component mounts
refetchOnReconnecttrueRefetch when the network reconnects

The v5 Syntax Changes

v4v5
useQuery(['key'], fn)useQuery({ queryKey, queryFn })
isLoading (initial load)isPending
cacheTimegcTime
onSuccess on queryRemoved (use useEffect)
keepPreviousDataplaceholderData

The Provider

ComponentPurpose
QueryClientProviderMakes the client available
HydrationBoundaryInjects the SSR cache
ReactQueryDevtoolsShows the cache in development

The Query Flags

FlagMeaning
isPendingNo data yet
isLoadingisPending && isFetching
isFetchingA fetch is in progress
isErrorThe query failed
errorThe error object
dataThe resolved data

Best Practices

✅ Do This:

// Use the object syntax.
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });  // ✅

// Set a staleTime above 0.
staleTime: 1000 * 60 * 5  // ✅

// Create the client at the module scope in client-only apps.
const queryClient = new QueryClient();  // ✅

// Create the client per request in SSR.
const [queryClient] = useState(() => new QueryClient());  // ✅

// Use the DevTools in development.
<ReactQueryDevtools />  // ✅

// Use the array form for the query key.
queryKey: ['user', userId]  // ✅

// Use isPending for the initial load.
if (isPending) return <Loading />;  // ✅

❌ Don’t Do This:

// Don't use the v4 array syntax.
useQuery(['todos'], fetchTodos);  // ❌

// Don't use the query callbacks.
onSuccess: (data) => {}  // ❌ Removed from queries

// Don't use isLoading for the initial load.
if (isLoading) return <Loading />;  // ⚠️ Changed meaning

// Don't use cacheTime.
cacheTime: 1000  // ❌ Use gcTime

// Don't share the client across SSR requests.
const queryClient = new QueryClient(); // module scope in SSR  // ❌

// Don't forget the provider.
useQuery({ ... });  // ❌ Without the provider, it throws

// Don't set staleTime to 0 everywhere.
staleTime: 0  // ⚠️ Aggressive refetching

Common Pitfalls

PitfallWhy It HappensFix
useQuery is not a functionMissing the providerWrap with QueryClientProvider
Redundant refetchesstaleTime is 0Set a staleTime above 0
Wrong loading stateisLoading in v5Use isPending
Callbacks do not runRemoved from queries in v5Use useEffect
SSR data leaksClient at module scopeCreate per request
Cache grows unboundedgcTime is too longSet a reasonable gcTime
Type errorsv4 syntaxUse the object syntax

Real-World Examples

1. The QueryClient

const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60000 } } });

2. The Provider

<QueryClientProvider client={queryClient}><App /></QueryClientProvider>

3. The First Query

useQuery({ queryKey: ['todos'], queryFn: fetchTodos });

4. The v5 Syntax

useQuery({ queryKey: ['todos'], queryFn: fetchTodos, staleTime: 5000 });

5. The SSR Provider

const [queryClient] = useState(() => new QueryClient());

6. The DevTools

<ReactQueryDevtools initialIsOpen={false} />

7. The Default Options

defaultOptions: { queries: { staleTime: 60000, retry: 2 } }

8. The Per-Query Override

useQuery({ queryKey: ['user', id], queryFn: fetchUser, enabled: !!id });

9. The SSR Hydration

<HydrationBoundary state={dehydratedState}><App /></HydrationBoundary>

10. The Complete Pattern

const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}><App /></QueryClientProvider>

Visual

The QueryClient

┌────────────────────────────────────────────────────────────┐
│  THE QUERY CLIENT                                          │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  QueryClient                                      │     │
│  │    - defaultOptions                                │     │
│  │    - query cache                                   │     │
│  │    - mutation cache                                │     │
│  │                                                   │     │
│  │  queryClient.invalidateQueries()                   │     │
│  │  queryClient.prefetchQuery()                       │     │
│  │  queryClient.setQueryData()                        │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The client holds the cache for the entire application.    │
└────────────────────────────────────────────────────────────┘

The Provider

┌────────────────────────────────────────────────────────────┐
│  THE PROVIDER                                              │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <QueryClientProvider client={queryClient}>        │     │
│  │    <App />                                         │     │
│  │  </QueryClientProvider>                            │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The provider makes the client available to every          │
│  component in the tree. Without it, useQuery throws.       │
└────────────────────────────────────────────────────────────┘

The Query Lifecycle

┌────────────────────────────────────────────────────────────┐
│  THE QUERY LIFECYCLE                                       │
│                                                            │
│  1. The component mounts.                                  │
│  2. useQuery reads the cache by the queryKey.              │
│  3. If the cache has fresh data, it is returned.           │
│  4. If the cache is empty or stale, the queryFn runs.      │
│  5. The data is cached, and the component re-renders.      │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  isPending: true → no data yet                     │     │
│  │  isFetching: true → a fetch is in progress         │     │
│  │  data: the resolved value                          │     │
│  │  error: the error object                           │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

The v5 Changes

┌────────────────────────────────────────────────────────────┐
│  THE v5 CHANGES                                            │
│                                                            │
│  OBJECT SYNTAX (required)                                  │
│  ┌──────────────────────────────────────────────────┐     │
│  │  useQuery({ queryKey, queryFn })                   │     │
│  │  Not useQuery(['key'], fn)                         │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  isLoading → isPending                                     │
│  ┌──────────────────────────────────────────────────┐     │
│  │  isPending: no data yet                            │     │
│  │  isLoading: isPending && isFetching                │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  cacheTime → gcTime                                        │
│  ┌──────────────────────────────────────────────────┐     │
│  │  gcTime: how long inactive data stays in memory    │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  Query callbacks removed                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  onSuccess, onError, onSettled                     │     │
│  │  Use useEffect for the query side effects          │     │
│  └──────────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
QueryClientHolds the cache and the default options
QueryClientProviderMakes the client available
useQueryThe data-fetching hook
queryKeyThe cache identifier
queryFnThe fetch function
staleTimeHow long the data is fresh
gcTimeHow long inactive data stays
isPendingNo data yet
isFetchingA fetch is in progress
HydrationBoundaryInjects the SSR cache

Key takeaways:

  • The QueryClient is the central object. It holds the cache, the default options, and the methods for invalidation and prefetching. Create it once at the module scope in a client-only app, and per request in an SSR app .
  • The QueryClientProvider makes the client available. Without it, the useQuery hook throws. The provider wraps the root component, and the DevTools are placed inside it .
  • The staleTime determines the refetch frequency. The default is 0, which means the data is immediately stale, and a background refetch runs on every mount, focus, and reconnect. Set a higher value to reduce the refetches .
  • The gcTime determines the cache lifetime. The default is 5 minutes. After the window, the inactive data is garbage collected, and the next mount refetches .
  • The v5 object syntax is required. The function overloads are removed. The useQuery({ queryKey, queryFn }) form is the only one .
  • The isPending flag is the initial load state. The isLoading flag changed in v5 to mean isPending && isFetching, which is true only during the first fetch .
  • The query callbacks are removed in v5. The onSuccess, onError, and onSettled callbacks no longer work on queries. They still work on mutations. The useEffect is the alternative .
  • The SSR setup creates the client per request. A process-wide client would leak one user’s cached data into another user’s HTML. The useState initializer or the framework’s per-request context ensures a fresh client .

Remember: The TanStack Query setup is the first step in every data-fetching application. The QueryClient holds the cache and the defaults, the QueryClientProvider makes it available, and the useQuery hook is the entry point. The staleTime and the gcTime control the freshness and the cache lifetime. The v5 object syntax is required, the isPending flag is the initial load state, and the query callbacks are removed. The result is an application where the server state is cached, deduplicated, and kept in sync, and where the components render the data without the manual loading and error management.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!