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 queryFn must 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

  1. Copying query data into useState — the copy goes stale. Use the query data directly or derive from it.
  2. Missing variables in the key — different filters overwrite each other's cache.
  3. Not throwing on HTTP errors — fetch resolves on 404/500; check res.ok.
  4. 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.