TanStack Query v5 in Practice: Query Keys, staleTime vs gcTime, Mutations and SSR Hydration
Key takeaways
TanStack Query is a server-state cache, not a fetching helper. Once you treat query keys as dependencies and understand staleTime, gcTime and invalidation, most of its 'surprising' refetches and stale screens stop being surprising.
What TanStack Query actually is
The most useful mental shift with TanStack Query (formerly React Query) is that it is not a nicer way to call fetch. It is a cache for server state: data that lives somewhere else, that other people can change, and that is therefore always potentially out of date the moment you receive it. Everything in the library, including the defaults that confuse people, follows from that premise.
The typical useEffect + useState approach gives you a single component’s copy of the data with no notion of freshness. Two components that need the same user list make two requests, show two loading spinners, and can disagree about what the list contains after one of them mutates it. You can fix each of those by hand, and people do, but you end up writing a worse version of this library.
// Hand-rolled: per-component, no dedupe, no freshness, race-prone
const [data, setData] = useState<User[] | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
fetch('/api/users')
.then((res) => res.json())
.then((users) => { if (!cancelled) setData(users); })
.catch((err) => { if (!cancelled) setError(err); });
return () => { cancelled = true; };
}, []);
// TanStack Query: shared cache entry keyed by ['users']
const { data, error, isPending } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
The second version is shorter, but the real win is that every component using ['users'] shares one cache entry, one in-flight request, and one invalidation point. This article covers v5 (@tanstack/react-query@5); several v4 options mentioned in older tutorials no longer exist, and I will point them out as we go.
Setup, and the first error everyone sees
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export default function Providers({ children }: { children: React.ReactNode }) {
// useState guarantees one client per browser session, not one per render
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000, // v5 name; `cacheTime` is gone
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
If a component calls useQuery outside this provider you get No QueryClient set, use QueryClientProvider to set one. In practice this shows up in two places: unit tests that render a component without wrapping it, and Next.js layouts where the provider was added to one route group but not another.
Creating the client inside useState (or with useRef) matters. new QueryClient() directly in the component body builds a fresh, empty cache on every render and you will see requests fire in a loop in DevTools.
Query keys are dependencies, not labels
The query key is how the cache identifies data, and it works like a useEffect dependency array: everything the query function reads should be in the key. When the key changes, TanStack Query treats it as a different query, looks it up in the cache, and fetches it if needed.
function UserPosts({ userId, status }: { userId: number; status: 'draft' | 'published' }) {
return useQuery({
queryKey: ['users', userId, 'posts', { status }],
queryFn: () => fetchPosts(userId, status),
});
}
Keys are hashed deterministically. Object key order does not matter ({ a, b } and { b, a } hash the same), but array position does. Because matching for invalidation is prefix-based, structuring keys from general to specific pays off: ['users'] invalidates every user-related query, ['users', 5] only the ones for user 5.
The bug this prevents is subtle. I have seen, and written, code like this:
// Bug: status is read but not part of the key
useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPosts(userId, status),
});
Switching the status filter does nothing, because as far as the cache is concerned nothing changed. Then someone “fixes” it by calling refetch() in an effect, and now the drafts and published lists overwrite each other in the same cache slot. The first time I tracked one of these down, the tell was in DevTools: one query entry where I expected two. The official ESLint plugin (@tanstack/eslint-plugin-query) has an exhaustive-deps rule that catches exactly this, and I now treat it as mandatory.
For larger apps, a key factory keeps keys consistent, and the queryOptions helper ties the key and function together so they cannot drift apart:
import { queryOptions } from '@tanstack/react-query';
export const userQueries = {
all: () => ['users'] as const,
list: () =>
queryOptions({
queryKey: [...userQueries.all(), 'list'],
queryFn: fetchUsers,
}),
detail: (id: number) =>
queryOptions({
queryKey: [...userQueries.all(), id],
queryFn: () => fetchUser(id),
staleTime: 30_000,
}),
};
// Usage: the same object works for useQuery, prefetchQuery and getQueryData
const { data } = useQuery(userQueries.detail(id));
A side benefit of queryOptions is typing: queryClient.getQueryData(userQueries.detail(id).queryKey) is inferred as User | undefined instead of unknown.
staleTime vs gcTime
These two options are the source of most “why is it refetching” and “why is it showing old data” questions, and they measure different things.
| Option | Default | What it controls |
|---|---|---|
staleTime | 0 | How long data counts as fresh. Fresh data is served from cache with no refetch on mount, focus or reconnect. |
gcTime | 5 minutes | How long an inactive query (no mounted observers) stays in memory before being garbage collected. Called cacheTime before v5. |
With the defaults, data is stale immediately. That does not mean it is thrown away. A stale query still renders its cached data instantly; it just also triggers a background refetch whenever a new component mounts with that key, the window regains focus, or the network reconnects. gcTime only starts counting once nothing is using the query.
Choosing staleTime is a product question: how wrong can this data be before a user notices or cares? A list of countries can be Infinity. A dashboard counter might be 30 seconds. A bank balance should stay at 0 and be invalidated explicitly after transfers. Setting a sensible global default (a minute or so) and overriding per query tends to work better than leaving everything at 0.
One constraint: if gcTime is shorter than staleTime, data can be evicted while still “fresh”, which defeats the point. Keep gcTime at least as long as staleTime.
Window-focus refetching surprises
refetchOnWindowFocus defaults to true. Combined with staleTime: 0, this means every time a user alt-tabs back to your app, every mounted query refetches. That is intentional (the user was away, data may have changed), but it catches people in a few specific ways:
- Forms get reset. If you copy query data into form state with an effect that runs whenever
datachanges, a focus refetch that returns a new object reference can overwrite what the user typed. Initialize form state once (for example with akeyon the form, ordefaultValuesonly on first load) rather than syncing on everydatachange. - Debugging looks haunted. Opening browser DevTools and clicking back into the page counts as a focus event, so requests appear “on their own” in the Network tab.
- Expensive endpoints get hammered. Reports and exports should not rerun on focus.
The fix is usually not refetchOnWindowFocus: false globally. Raising staleTime on the queries that do not need it keeps focus refetching working for the data that does. Note that structural sharing means an identical response does not produce a new data reference, so components that only read data will not re-render when nothing changed.
isPending vs isLoading vs isFetching
v5 renamed the status flags, and the old names moved meaning, which breaks upgrades quietly.
isPending(status === 'pending'): there is no data for this key yet.isFetching: a request is in flight right now, including background refetches.isLoading:isPending && isFetching. The very first fetch is running. (In v4, this is whatisInitialLoadingmeant.)
The trap is disabled queries:
const { data, isLoading, isPending } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId!),
enabled: userId != null,
});
if (isLoading) return <Spinner />; // false while disabled
return <Profile user={data!} />; // crashes: data is undefined
While enabled is false, the query is pending but not fetching, so isLoading is false and the code falls through with no data. Checking isPending (or simply if (!data)) handles both cases. For background activity, show a subtle indicator on isFetching && !isPending rather than replacing the content with a spinner.
For mutations the rename is simpler: v4’s mutation.isLoading is mutation.isPending in v5.
Mutations and invalidation
Mutations change server state, so after one succeeds, some cached queries are wrong. The simplest correct approach is to invalidate them and let TanStack Query refetch whatever is currently on screen:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { name: string; email: string }) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Create failed: ${res.status}`);
return (await res.json()) as User;
},
// Returning the promise keeps the mutation pending until the refetch finishes
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
});
}
A few details that matter:
fetchdoes not reject on HTTP errors. Without theres.okcheck, a 500 response resolves successfully,onSuccessruns, and the mutation reports success. The same applies to query functions. If you usefetch, throw yourself.- Invalidation is prefix-based by default.
invalidateQueries({ queryKey: ['users'] })marks['users'],['users', 5]and['users', 5, 'posts']as stale. Active ones refetch immediately; inactive ones refetch the next time they are used. Passexact: trueto match one key only. - Returning the promise from
onSuccessmeansisPendingstays true until the fresh list has arrived, so the button does not re-enable while the old list is still displayed.
When the mutation response already contains the updated entity, you can write it straight into the cache with setQueryData and skip a round trip. Be careful with this: it is easy to update the detail query and forget the list queries that also contain that entity. Invalidating is slower but harder to get wrong.
Optimistic updates with rollback
Optimistic updates make the UI reflect a change before the server confirms it. The pattern has four steps, and skipping any of them causes a specific bug.
type Todo = { id: number; title: string; completed: boolean };
export function useToggleTodo() {
const queryClient = useQueryClient();
const key = ['todos'];
return useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
patchTodo(id, { completed }),
onMutate: async ({ id, completed }) => {
// 1. Stop in-flight refetches from overwriting the optimistic value
await queryClient.cancelQueries({ queryKey: key });
// 2. Snapshot for rollback
const previous = queryClient.getQueryData<Todo[]>(key);
// 3. Apply the optimistic change
queryClient.setQueryData<Todo[]>(key, (old) =>
old?.map((t) => (t.id === id ? { ...t, completed } : t)),
);
return { previous };
},
onError: (_err, _vars, context) => {
// Roll back to the snapshot
if (context?.previous) queryClient.setQueryData(key, context.previous);
},
// 4. Resync with the server either way
onSettled: () => queryClient.invalidateQueries({ queryKey: key }),
});
}
- Without
cancelQueries, a background refetch that started before the click can land after it and briefly revert the checkbox. - Without the snapshot, a failed request leaves the UI showing something the server rejected.
- Without
onSettledinvalidation, the cache never learns about server-side changes (timestamps, computed fields) that the optimistic version could not know. - The updater receives
oldas possiblyundefinedif the list was never loaded;old?.mapavoids aCannot read properties of undefined (reading 'map')crash.
For simple cases, v5 also supports rendering the pending mutation’s variables directly in the UI (for example showing a greyed-out new row while isPending is true) instead of touching the cache at all. That is easier to reason about when only one component needs to show the pending state.
Infinite queries
v5 requires initialPageParam; omitting it is a type error and the query function no longer has a default pageParam.
import { useInfiniteQuery } from '@tanstack/react-query';
type Page = { items: User[]; nextCursor: string | null };
function UserFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['users', 'feed'],
queryFn: ({ pageParam }): Promise<Page> =>
fetch(`/api/users?cursor=${pageParam ?? ''}`).then((r) => r.json()),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor, // null/undefined = no more pages
});
return (
<>
{data?.pages.flatMap((p) => p.items).map((u) => <UserRow key={u.id} user={u} />)}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : hasNextPage ? 'Load more' : 'No more'}
</button>
</>
);
}
Cursor-based pagination is a better fit than page numbers here. When an infinite query is refetched (on focus, or after invalidation), TanStack Query refetches every loaded page sequentially from the first one. With page numbers, an item inserted at the top shifts everything and page boundaries duplicate or skip rows; cursors do not have that problem. Loading fifty pages and then invalidating also means fifty sequential requests, which is worth knowing before you invalidate a long feed after every like. The maxPages option limits how many pages are kept.
For regular paginated tables, v5 replaced the keepPreviousData: true option with placeholderData: keepPreviousData (a function you import) so the previous page stays visible while the next one loads.
Error handling in v5
v5 removed onSuccess, onError and onSettled from useQuery. Code copied from v4 tutorials still compiles in plain JavaScript and silently never runs, which is the worst kind of breakage. The reasoning from the maintainers was that these callbacks ran once per observer, so a query used in three components fired three toasts, and that they encouraged syncing server data into local state.
The replacement for global handling is a callback on the cache itself, which runs once per query failure:
import { QueryCache, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Only toast for background refetch failures when we already show data
if (query.state.data !== undefined) {
toast.error(`Could not refresh: ${error.message}`);
}
},
}),
});
Mutations still accept onError per call and in defaultOptions.mutations. For queries inside components, render from error directly, or use throwOnError: true (formerly useErrorBoundary) to hand errors to a React error boundary.
Two related pitfalls:
- Retries hide errors in development. Queries retry three times by default with exponential backoff, so a broken endpoint shows a spinner for several seconds before the error appears. Set
retry: falsein tests; otherwise tests time out instead of failing with a useful message. - Returning
undefinedfrom a query function is an error. v5 does not allowundefinedas query data and logs a “Query data cannot be undefined” error naming the query key. Returnnullfor “nothing found”.
For TypeScript, v5 defaults the error type to Error. If your API layer throws a custom class, you can register it once instead of annotating every hook:
declare module '@tanstack/react-query' {
interface Register {
defaultError: ApiError;
}
}
SSR and hydration with HydrationBoundary
With the Next.js App Router, you can prefetch on the server, serialize the cache, and hydrate it on the client so the first render already has data. v5 renamed Hydrate to HydrationBoundary.
// app/users/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { userQueries } from '@/queries/users';
import UserList from './user-list';
export default async function UsersPage() {
const queryClient = new QueryClient(); // new per request
await queryClient.prefetchQuery(userQueries.list());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList /> {/* client component calling useQuery(userQueries.list()) */}
</HydrationBoundary>
);
}
What goes wrong here:
- A shared server-side QueryClient leaks data between users. A
QueryClientcreated at module scope on the server lives across requests, so one user’s cached data can be served to another. Create one per request on the server; on the browser, one per session. - Immediate client refetch. With
staleTime: 0, the client considers the hydrated data stale on mount and refetches it straight away, doubling the work. Set a non-zerostaleTime(the docs suggest something above 0 for SSR setups) so the hydrated data is used as-is. - Keys must match exactly. If the server prefetches
['users']and the client component uses['users', { page: 1 }], the hydrated entry is never read and the client fetches from scratch. SharingqueryOptionsbetween server and client code removes this whole class of mismatch. prefetchQuerynever throws. Failed prefetches are simply not in the dehydrated state; the client then fetches and handles the error. That is usually what you want, but it means a broken API does not fail your server render.
When not to use it
TanStack Query is the wrong tool for state that has no server source of truth: whether a modal is open, the current step of a wizard, an unsaved draft, a theme toggle. Putting those in queries works mechanically (setQueryData on a key with no queryFn), but you inherit refetch, staleness and garbage-collection behavior that has no meaning for them, and a gcTime expiry can silently wipe a draft. Use React state or a small store like Zustand for that, and keep queries for data you fetched.
It also does not replace a normalized client cache. If the same entity appears in many different queries and you need every one of them to update when it changes, you either invalidate broadly or write updates into several keys. GraphQL clients with normalized caches handle that case more naturally. If you only need basic stale-while-revalidate fetching without mutations helpers or DevTools, SWR is a smaller alternative.
My own rule of thumb after a few migrations: if a piece of state is something I would be comfortable re-downloading at any moment, it belongs in a query. If re-downloading it would lose something the user did, it does not.
Further reading
The official TanStack Query docs, and the v5 migration guide, which lists every renamed option — useful when an older tutorial’s cacheTime or isLoading does not behave the way it describes.
Related Articles
- SWR: React Data Fetching by Vercel: a lighter stale-while-revalidate alternative
- Zustand: client state that should not live in a query cache
- Next.js App Router: Server Components and where server prefetching fits
- React from Usage to Internals: Fiber Reconciliation, Diffing, Hooks and Concurrent Rendering: Suspense and concurrent rendering behind
useSuspenseQuery