@tanstack/react-query
TanStack Query's React bindings manage server state: the data you fetch over the network but do not own. useQuery gives you caching, request deduplication, background refetching, and staleness tracking keyed by a query key; useMutation handles writes plus cache invalidation. It replaces hand-rolled useEffect fetching and most of the store code teams wrote just to hold API responses. It is protocol agnostic, so it works with REST, GraphQL, or anything that returns a promise.
If a React app talks to an API without a framework-owned data layer, this is the default choice and deserves to be. Budget real time for query key discipline, and do not bolt it onto a framework that already caches for you.
Use it if
- Multiple components fetch the same data and you keep rebuilding loading, error, and refetch logic by hand
- You need caching, deduplication, and background refetch without adopting an entire data framework
- You talk to REST or any promise-returning API and do not want to be pushed toward GraphQL
- You want pagination, infinite scroll, and optimistic updates as documented patterns instead of bespoke code
- Your problem is client state (forms, UI flags, wizards): this is a server cache, not a Zustand or Redux replacement
- Your framework already owns data fetching, like Next.js server components or Remix loaders: a second client cache means two sources of truth to keep honest
- Your app makes a handful of fetches on one page: query keys, staleTime, and gcTime are real learning overhead for something a small fetch hook covers
- You need normalized caching where different queries share the same cached entities: Apollo Client's normalized store fits GraphQL data better
Setup reality
Install is easy: one package with a single react peer dependency, wrap the app in QueryClientProvider, done. The cost shows up later. The default staleTime of 0 refetches on every window focus, so you tune defaults early or watch the network tab churn. You need a query key convention the whole team actually follows. SSR requires prefetch plus dehydrate plus HydrationBoundary wiring per route. And the v4 to v5 migration renamed a lot (cacheTime to gcTime, status loading to pending, object-only signatures), so pre-2024 blog posts and old Stack Overflow answers actively mislead.
Patterns
Fetch and cache data with useQuerybasic-query
import { useQuery } from '@tanstack/react-query';
function Todos() {
const { data, isPending, error } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}v5 only accepts the object form, and v4's isLoading is now isPending; fetch does not reject on 404/500, so throw on !res.ok yourself.
Create the client with sane defaultsprovider-setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000, retry: 1 },
},
});
export function App({ children }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}The default staleTime is 0, meaning a refetch on every mount and window focus; set a real value unless you want that.
Write data, then invalidate the cachemutation-invalidate
import { useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then((r) => r.json()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
mutation.mutate({ title: 'buy milk' });invalidateQueries marks matching queries stale and refetches the active ones; it matches key prefixes, so ['todos'] also hits ['todos', 5].
Run a query only when its input existsdependent-query
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
});
const userId = user?.id;
const { data: projects } = useQuery({
queryKey: ['projects', userId],
queryFn: () => getProjectsByUser(userId),
enabled: !!userId,
});A disabled query reports status pending with fetchStatus idle, so do not treat isPending alone as a spinner signal.
Load pages with useInfiniteQueryinfinite-scroll
import { useInfiniteQuery } from '@tanstack/react-query';
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetch(`/api/projects?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const items = data?.pages.flatMap((p) => p.items) ?? [];initialPageParam is required in v5; returning undefined (not null) from getNextPageParam is what sets hasNextPage to false.
Keep old data visible while a new page loadspaginated-query
import { keepPreviousData, useQuery } from '@tanstack/react-query';
const { data, isPlaceholderData } = useQuery({
queryKey: ['todos', page],
queryFn: () => fetchTodos(page),
placeholderData: keepPreviousData,
});The v4 keepPreviousData boolean option is gone in v5; import the keepPreviousData function and pass it to placeholderData.
Update the UI before the server confirmsoptimistic-update
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) =>
old.map((t) => (t.id === newTodo.id ? newTodo : t))
);
return { previous };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});cancelQueries matters: without it an in-flight refetch can land after your setQueryData and overwrite the optimistic value.
Prefetch on the server and hydrate on the clientssr-hydration
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: getTodos });
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Todos />
</HydrationBoundary>
);
}Create a fresh QueryClient per request on the server or you leak data between users; v4's Hydrate component is now HydrationBoundary.
Define a query once with queryOptionsshared-query-options
import { queryOptions, useQuery } from '@tanstack/react-query';
export const todoOptions = (id) =>
queryOptions({
queryKey: ['todos', id],
queryFn: () => fetchTodo(id),
staleTime: 5 * 60 * 1000,
});
const { data } = useQuery(todoOptions(5));
// also works: queryClient.prefetchQuery(todoOptions(5))queryOptions keeps key, fetcher, and types in one place, so useQuery, prefetchQuery, and getQueryData stop drifting apart.
Use Suspense instead of isPending checkssuspense-query
import { useSuspenseQuery } from '@tanstack/react-query';
function Todos() {
const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos });
return <List items={data} />;
}
// <Suspense fallback={<Spinner />}><Todos /></Suspense>data is never undefined here, but you must provide both a Suspense boundary and an error boundary or failures bubble to the root.
Refetch on an intervalpolling
useQuery({
queryKey: ['job', jobId],
queryFn: () => fetchJobStatus(jobId),
refetchInterval: (query) =>
query.state.data?.status === 'done' ? false : 2000,
});Returning false from the refetchInterval function stops polling; by default polling pauses when the tab is backgrounded unless refetchIntervalInBackground is set.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| swr | npm | You want the same stale-while-revalidate idea with a smaller API surface and you are already in the Vercel ecosystem. |
| @apollo/client | npm | You are all-in on GraphQL and want a normalized cache where entities are shared across queries. |
| @reduxjs/toolkit | npm | You already run Redux and want data fetching (RTK Query) inside the store you have instead of a second cache. |