Articles › React
Data Fetching in React with TanStack Query: Caching, Mutations and Pagination
Fetch server data the right way in React with TanStack Query: queries, query keys, caching and staleTime, mutations, invalidation, optimistic updates, pagination and prefetching.
Fetching data with useEffect and useState looks simple until you need loading states, error handling, caching, refetching when the window regains focus, deduplicating identical requests, retries, pagination and keeping data in sync after a mutation. TanStack Query (formerly React Query) handles all of that, so your components describe what data they need instead of how to manage it.
Setup
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // data is fresh for 1 minute
retry: 1,
refetchOnWindowFocus: true,
},
},
});
createRoot(document.getElementById("root")).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
Add @tanstack/react-query-devtools during development to inspect the cache visually.
Your first query
import { useQuery } from "@tanstack/react-query";
async function fetchArticles() {
const res = await fetch("/api/articles");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function ArticleList() {
const { data, isPending, isError, error, isFetching } = useQuery({
queryKey: ["articles"],
queryFn: fetchArticles,
});
if (isPending) return <Spinner />;
if (isError) return <ErrorBox message={error.message} />;
return (
<>
{isFetching && <small>Refreshing…</small>}
{data.map((a) => <ArticleCard key={a.id} article={a} />)}
</>
);
}
isPending— no data yet (first load).isFetching— any request in flight, including background refreshes.- The
queryFnmust throw on failure so TanStack Query knows it errored.
Query keys: the heart of the cache
The queryKey uniquely identifies cached data. Include every variable the query depends on:
function useArticle(slug) {
return useQuery({
queryKey: ["articles", slug],
queryFn: ({ signal }) => api.getArticle(slug, { signal }),
enabled: Boolean(slug), // don't run until we have a slug
});
}
function useArticleSearch(filters) {
return useQuery({
queryKey: ["articles", "list", filters], // { tag, page, q }
queryFn: () => api.searchArticles(filters),
});
}
When the key changes, a new query runs; when two components use the same key, they share one request and one cache entry. The signal lets TanStack Query cancel outdated requests automatically.
Tip: Organize keys hierarchically (
["articles"],["articles", "list", filters],["articles", slug]). Then invalidating["articles"]refreshes everything article-related at once.
staleTime vs gcTime
Two settings control caching:
| Option | Meaning | Default |
|---|---|---|
staleTime |
how long data is considered fresh (no refetch) | 0 |
gcTime |
how long unused data stays in memory | 5 minutes |
With the default staleTime: 0, cached data is shown instantly but refetched in the background on mount or window focus ("stale-while-revalidate"). For data that rarely changes — a list of countries, published articles — raise staleTime to minutes or more.
Mutations: changing data
import { useMutation, useQueryClient } from "@tanstack/react-query";
function useCreateComment(slug) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (text) => api.addComment(slug, text),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["comments", slug] });
},
});
}
function CommentForm({ slug }) {
const createComment = useCreateComment(slug);
const [text, setText] = useState("");
return (
<form onSubmit={(e) => { e.preventDefault(); createComment.mutate(text, { onSuccess: () => setText("") }); }}>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button disabled={createComment.isPending}>
{createComment.isPending ? "Sending…" : "Send"}
</button>
{createComment.isError && <p role="alert">{createComment.error.message}</p>}
</form>
);
}
Invalidation marks queries as stale and refetches active ones, so the UI shows fresh server data after every change.
Optimistic updates
For instant feedback (likes, toggles, reordering), update the cache before the server responds and roll back on error:
useMutation({
mutationFn: (articleId) => api.like(articleId),
onMutate: async (articleId) => {
await queryClient.cancelQueries({ queryKey: ["articles", articleId] });
const previous = queryClient.getQueryData(["articles", articleId]);
queryClient.setQueryData(["articles", articleId], (old) => ({ ...old, likes: old.likes + 1, liked: true }));
return { previous };
},
onError: (_err, articleId, context) => {
queryClient.setQueryData(["articles", articleId], context.previous);
},
onSettled: (_data, _err, articleId) => {
queryClient.invalidateQueries({ queryKey: ["articles", articleId] });
},
});
Pagination without flicker
import { keepPreviousData } from "@tanstack/react-query";
function ArticlePages() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["articles", "list", { page }],
queryFn: () => api.getArticles({ page }),
placeholderData: keepPreviousData, // keep showing the old page while loading the next
});
return (
<>
<List items={data?.items ?? []} />
<button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>Previous</button>
<button onClick={() => setPage((p) => p + 1)} disabled={isPlaceholderData || !data?.hasMore}>Next</button>
</>
);
}
For "load more" feeds, useInfiniteQuery manages pages and cursors for you.
Prefetching for instant navigation
function ArticleLink({ article }) {
const queryClient = useQueryClient();
return (
<Link
to={`/articles/${article.slug}`}
onMouseEnter={() =>
queryClient.prefetchQuery({
queryKey: ["articles", article.slug],
queryFn: () => api.getArticle(article.slug),
})
}
>
{article.title}
</Link>
);
}
By the time the user clicks, the article is often already in the cache.
Organize queries in custom hooks
Keep keys and fetchers in one place so components stay declarative:
export const articleKeys = {
all: ["articles"],
list: (filters) => ["articles", "list", filters],
detail: (slug) => ["articles", slug],
};
export const useArticles = (filters) =>
useQuery({ queryKey: articleKeys.list(filters), queryFn: () => api.searchArticles(filters) });
Common mistakes
- Copying query data into
useState— the copy goes stale. Use the query data directly or derive from it. - Missing variables in the key — different filters overwrite each other's cache.
- Not throwing on HTTP errors —
fetchresolves on 404/500; checkres.ok. - Using it for client state — form inputs and UI toggles belong in
useState, not the query cache.
Summary
TanStack Query treats server data as a cache with clear rules: query keys identify data, staleTime controls freshness, mutations change data and invalidation keeps everything in sync. Add optimistic updates, pagination with placeholder data and prefetching, and your React app will feel instant while your components stay small and declarative.
FAQ
Do I still need Redux or Zustand with TanStack Query?
Often not for server data. You may still want a small store for complex client-only state.
How is this different from fetching in useEffect?
Same network request, but TanStack Query adds caching, deduplication, retries, background refetching, cancellation and devtools — code you'd otherwise write and maintain yourself.
Does it work with Axios or GraphQL?
Yes. queryFn can be any function that returns a Promise.