React 63 ⚛️ TanStack Query Setup and Query Client
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
| Option | Default | Purpose |
|---|---|---|
staleTime | 0 | How long the data is fresh |
gcTime | 5 * 60 * 1000 | How long inactive data stays in memory |
retry | 3 | The number of retries |
refetchOnWindowFocus | true | Refetch when the window refocuses |
refetchOnMount | true | Refetch when the component mounts |
refetchOnReconnect | true | Refetch when the network reconnects |
The v5 Syntax Changes
| v4 | v5 |
|---|---|
useQuery(['key'], fn) | useQuery({ queryKey, queryFn }) |
isLoading (initial load) | isPending |
cacheTime | gcTime |
onSuccess on query | Removed (use useEffect) |
keepPreviousData | placeholderData |
The Provider
| Component | Purpose |
|---|---|
QueryClientProvider | Makes the client available |
HydrationBoundary | Injects the SSR cache |
ReactQueryDevtools | Shows the cache in development |
The Query Flags
| Flag | Meaning |
|---|---|
isPending | No data yet |
isLoading | isPending && isFetching |
isFetching | A fetch is in progress |
isError | The query failed |
error | The error object |
data | The 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
useQuery is not a function | Missing the provider | Wrap with QueryClientProvider |
| Redundant refetches | staleTime is 0 | Set a staleTime above 0 |
| Wrong loading state | isLoading in v5 | Use isPending |
| Callbacks do not run | Removed from queries in v5 | Use useEffect |
| SSR data leaks | Client at module scope | Create per request |
| Cache grows unbounded | gcTime is too long | Set a reasonable gcTime |
| Type errors | v4 syntax | Use 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
| Item | Value |
|---|---|
QueryClient | Holds the cache and the default options |
QueryClientProvider | Makes the client available |
useQuery | The data-fetching hook |
queryKey | The cache identifier |
queryFn | The fetch function |
staleTime | How long the data is fresh |
gcTime | How long inactive data stays |
isPending | No data yet |
isFetching | A fetch is in progress |
HydrationBoundary | Injects the SSR cache |
Key takeaways:
- The
QueryClientis 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
QueryClientProvidermakes the client available. Without it, theuseQueryhook throws. The provider wraps the root component, and the DevTools are placed inside it . - The
staleTimedetermines the refetch frequency. The default is0, 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
gcTimedetermines 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
isPendingflag is the initial load state. TheisLoadingflag changed in v5 to meanisPending && isFetching, which is true only during the first fetch . - The query callbacks are removed in v5. The
onSuccess,onError, andonSettledcallbacks no longer work on queries. They still work on mutations. TheuseEffectis 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
useStateinitializer 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!