Query client
A per-request client on the server, a singleton in the browser, sensible defaults, and retry rules that give up on client errors.
From: the resort website and the company website with its CMS. A teammate set up the client
in both projects; the retry and dehydration fix and the admin staleTime below
are mine.
One client per request, one per tab
In the App Router, the same code runs on the server and in the browser, and they need different lifetimes for the client:
import { isServer, QueryClient } from '@tanstack/react-query';
import { cache } from 'react';
let browserQueryClient: QueryClient | undefined;
// React's cache() scopes this to a single server request.
const getServerQueryClient = cache(() => makeQueryClient());
export function getQueryClient() {
if (isServer) {
return getServerQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}- Server: a new client for every request, so one visitor's data can never
leak into another visitor's page.
cache()meansgenerateMetadataand the page component in the same request share one client, and its cache. - Browser: one client for the whole tab, so data survives client-side navigation.
Defaults
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: shouldRetryQuery,
refetchOnWindowFocus: process.env.NODE_ENV === 'production',
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
(query.state.status === 'pending' && query.state.fetchFailureReason === null),
},
},
});
}staleTimeabove zero. With the default of0, a query prefetched on the server is already stale when it reaches the browser and refetches straight away. Five minutes suits public content that rarely changes.- No refetch on window focus in development. Switching between the editor and the browser would otherwise refetch everything.
Retry rules
A failed request is retried up to twice in production, but some failures will
never succeed on a retry. The first version only gave up on 401 and 403:
if (error instanceof AxiosError && [401, 403].includes(error.response?.status ?? 0)) {
return false;
}With that rule, a 404 for a missing room or blog post was retried twice
before the page could show "not found", and a 400 or 422 was retried too.
I changed it to give up on every client error:
function shouldRetryQuery(failureCount: number, error: unknown) {
if (process.env.NODE_ENV === 'development') {
return false;
}
if (failureCount >= 2) {
return false;
}
if (error instanceof AxiosError) {
const status = error.response?.status ?? 0;
// Client errors (4xx) won't succeed on retry.
if (status >= 400 && status < 500) {
return false;
}
}
return true;
}The rule
4xx means the request itself is wrong, so asking again gives the same answer. Retry 5xx and network errors, which can be temporary.
In the same change, the dehydrate rule stopped sending a pending query to
the browser if it had already failed once (fetchFailureReason === null).
Otherwise the client would pick up a request that the server already knew was
failing.
Shorter staleTime for admin screens
Five minutes is right for a public page and wrong for a dashboard: an editor saves a record in one tab and expects to see it in the list. In the company CMS, admin queries use a shared constant instead:
/**
* Default `staleTime` for admin list/detail queries. Short enough that
* navigating list -> detail -> back to list within this window skips a
* refetch, long enough that edits made elsewhere still show up quickly.
*/
export const DEFAULT_QUERY_STALE_TIME_MS = 30 * 1000;| Content | staleTime | Why |
|---|---|---|
| Public pages (published blogs, home content) | 5 minutes | Changes rarely; prefetched on the server |
| Admin lists and details | 30 seconds | Editors expect their changes to show up |
Last updated on