Skip to content

React7 min read

TanStack Query Guide: React Data Fetching Done Right

A practical TanStack Query (React Query v5) guide: query keys, caching with staleTime and gcTime, mutations, optimistic updates, pagination and infinite scroll.

TanStack Query (formerly React Query) is a server-state library for React: you give it a query key and an async query function, and it handles caching, deduplication, background refetching, retries, pagination and mutations for you. In version 5, the current major, everything uses a single object argument (useQuery({ queryKey, queryFn })), and the first-load flag is isPending. This guide covers the patterns I use in production, with TypeScript examples.

Key takeaways

  • Query keys are the cache. Treat them like dependency arrays: everything the query function uses goes in the key.
  • staleTime (default 0) decides when to refetch. gcTime (default 5 minutes) decides when to forget unused data.
  • Use queryOptions() to define keys and fetchers once and reuse them in hooks, prefetching and invalidation.
  • After a mutation, invalidate the related queries. For instant UI, add optimistic updates with rollback.
  • Use placeholderData: keepPreviousData for paginated tables and useInfiniteQuery for "load more" and infinite scroll.
  • fetch does not throw on 4xx or 5xx responses. Throw yourself, or TanStack Query treats errors as success.

Setup: QueryClient and provider

Terminal
npm install @tanstack/react-query @tanstack/react-query-devtools
TSX
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";

export function Providers({ children }: { children: ReactNode }) {
  // useState so the client is created once per app instance (important with SSR)
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: { staleTime: 30_000 },
        },
      }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

The devtools are excluded from production builds by default. Keep them open while learning: watching queries go from fresh to stale to inactive explains caching faster than any article.

Your first query, and isPending vs isFetching

TSX
import { useQuery } from "@tanstack/react-query";

type Post = { id: number; title: string; body: string };

async function getPosts(): Promise<Post[]> {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts");
  if (!res.ok) throw new Error(`Failed to load posts (${res.status})`);
  return res.json();
}

export function Posts() {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ["posts"],
    queryFn: getPosts,
  });

  if (isPending) return <p>Loading posts...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return (
    <>
      {isFetching && <small>Refreshing...</small>}
      <ul>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}
Flag True when Typical UI
isPending No cached data yet Skeleton or spinner
isFetching Any request is running, including background refetches Small "refreshing" indicator
isLoading isPending && isFetching (first load in progress) Same as isPending, but false if the query is disabled
isError The last fetch failed after retries Error message with retry button
isSuccess Data is available Render data

After the isPending and isError checks, TypeScript narrows data to Post[], so no data?. is needed.

Query keys and queryOptions

The query key identifies the cached data. If your function depends on a variable, that variable must be in the key, or you will see data for the wrong ID.

TSX
import { queryOptions, useQuery, useQueryClient } from "@tanstack/react-query";

type Post = { id: number; title: string; body: string };

async function getPost(id: number): Promise<Post> {
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
  if (!res.ok) throw new Error(`Failed to load post ${id}`);
  return res.json();
}

export const postQueries = {
  all: () => ["posts"] as const,
  detail: (id: number) =>
    queryOptions({
      queryKey: [...postQueries.all(), id],
      queryFn: () => getPost(id),
      staleTime: 5 * 60_000,
    }),
};

export function PostDetail({ id }: { id: number }) {
  const { data, isPending } = useQuery(postQueries.detail(id));
  if (isPending) return <p>Loading...</p>;
  return <h1>{data?.title}</h1>;
}

export function PostLink({ id, title }: { id: number; title: string }) {
  const queryClient = useQueryClient();
  return (
    <a
      href={`/posts/${id}`}
      onMouseEnter={() => queryClient.prefetchQuery(postQueries.detail(id))}
    >
      {title}
    </a>
  );
}

queryOptions gives you one typed definition you can pass to useQuery, useSuspenseQuery, prefetchQuery and getQueryData, and the key carries the data type with it. Structure keys from general to specific (["posts"], then ["posts", 42]), because invalidation matches by prefix: invalidating ["posts"] also marks every post detail as stale.

Caching: staleTime vs gcTime

These two options confuse almost everyone at first.

  • staleTime is how long data is considered fresh. While fresh, mounting a component or refocusing the window will not refetch. The default is 0, so data is stale immediately and refetches on mount, window focus and reconnect.
  • gcTime (renamed from cacheTime in v5) is how long inactive data stays in memory after the last observer unmounts. The default is 5 minutes.

A good starting point: set a global staleTime of somewhere between 30 seconds and a few minutes for typical dashboard data, then override per query. Data that rarely changes (a country list, app settings) can use staleTime: Infinity. Live data like scores can poll with refetchInterval.

Mutations and cache invalidation

useMutation handles writes. After a successful write, invalidate the queries that are now out of date.

TSX
import { useMutation, useQueryClient } from "@tanstack/react-query";

type NewPost = { title: string; body: string };

async function createPost(input: NewPost) {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });
  if (!res.ok) throw new Error("Could not create post");
  return (await res.json()) as NewPost & { id: number };
}

export function NewPostButton() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["posts"] });
    },
  });

  return (
    <>
      <button
        disabled={mutation.isPending}
        onClick={() => mutation.mutate({ title: "Hello", body: "First post" })}
      >
        {mutation.isPending ? "Saving..." : "Create post"}
      </button>
      {mutation.isError && <p role="alert">{mutation.error.message}</p>}
    </>
  );
}

Returning (or awaiting) the invalidateQueries promise keeps the mutation in its pending state until the refetch finishes, so the button does not flip back before the list updates.

Optimistic updates with rollback

Optimistic updates show the change immediately and roll back if the server rejects it. The cache-based version:

TSX
import { useMutation, useQueryClient } from "@tanstack/react-query";

type Todo = { id: number; title: string; done: boolean };

async function toggleTodo(todo: Todo): Promise<Todo> {
  const res = await fetch(`/api/todos/${todo.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ done: !todo.done }),
  });
  if (!res.ok) throw new Error("Update failed");
  return res.json();
}

export function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: toggleTodo,
    onMutate: async (todo) => {
      // 1. Stop in-flight refetches from overwriting the optimistic value
      await queryClient.cancelQueries({ queryKey: ["todos"] });
      // 2. Snapshot the current value
      const previous = queryClient.getQueryData<Todo[]>(["todos"]);
      // 3. Write the optimistic value
      queryClient.setQueryData<Todo[]>(["todos"], (old = []) =>
        old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
      );
      // 4. Return the snapshot for rollback
      return { previous };
    },
    onError: (_error, _todo, onMutateResult) => {
      if (onMutateResult?.previous) {
        queryClient.setQueryData(["todos"], onMutateResult.previous);
      }
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
  });
}

The value returned from onMutate arrives as the third argument of onError and the fourth of onSettled. Recent v5 releases also pass a final context argument that exposes context.client, the QueryClient, but using useQueryClient() as above works across all v5 versions.

For simpler cases, skip the cache writes and render from the mutation's variables while isPending is true. The official optimistic updates guide shows both approaches. If you are using React 19 Actions instead, useOptimistic is the built-in alternative (see React 19 hooks).

Pagination with keepPreviousData

For a numbered table, put the page in the key and keep showing the previous page while the next one loads, instead of flashing a spinner.

TSX
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useState } from "react";

type Page<T> = { items: T[]; hasMore: boolean };
type User = { id: number; name: string };

async function getUsers(page: number): Promise<Page<User>> {
  const res = await fetch(`/api/users?page=${page}`);
  if (!res.ok) throw new Error("Failed to load users");
  return res.json();
}

export function UsersTable() {
  const [page, setPage] = useState(1);
  const { data, isPending, isPlaceholderData } = useQuery({
    queryKey: ["users", page],
    queryFn: () => getUsers(page),
    placeholderData: keepPreviousData,
  });

  if (isPending) return <p>Loading...</p>;

  return (
    <>
      <ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data?.items.map((u) => <li key={u.id}>{u.name}</li>)}
      </ul>
      <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
        Previous
      </button>
      <button
        onClick={() => setPage((p) => p + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next
      </button>
    </>
  );
}

In v5, keepPreviousData: true became placeholderData: keepPreviousData, and isPreviousData became isPlaceholderData.

Infinite queries for "load more"

TSX
import { useInfiniteQuery } from "@tanstack/react-query";

type FeedPage = { items: { id: string; text: string }[]; nextCursor: string | null };

async function getFeed(cursor: string | null): Promise<FeedPage> {
  const res = await fetch(`/api/feed${cursor ? `?cursor=${cursor}` : ""}`);
  if (!res.ok) throw new Error("Failed to load feed");
  return res.json();
}

export function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
    useInfiniteQuery({
      queryKey: ["feed"],
      queryFn: ({ pageParam }) => getFeed(pageParam),
      initialPageParam: null as string | null,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    });

  if (isPending) return <p>Loading...</p>;

  return (
    <>
      {data?.pages.flatMap((page) => page.items).map((item) => (
        <p key={item.id}>{item.text}</p>
      ))}
      <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
        {isFetchingNextPage ? "Loading more..." : hasNextPage ? "Load more" : "No more posts"}
      </button>
    </>
  );
}

initialPageParam is required in v5. Returning null or undefined from getNextPageParam sets hasNextPage to false. For very long feeds, the maxPages option limits how many pages stay in memory, and pairing this with list virtualization (covered in the React performance checklist) keeps the DOM small.

Common TanStack Query mistakes

  • Not throwing on HTTP errors. fetch resolves on 404 and 500. Check res.ok and throw.
  • Missing variables in the query key. Two different users end up sharing one cache entry.
  • Copying query data into useState. You lose background updates. Read from the query directly, and use select to derive data.
  • Creating new QueryClient() inside a component body without useState. Every render gets a fresh, empty cache.
  • Using onSuccess on useQuery. It was removed from queries in v5. Derive values during render, or use an effect only if you truly sync with something outside React.
  • Leaving staleTime at 0 everywhere. That is fine for correctness but causes more refetching than most apps need.
  • One giant query for a whole page. Split by resource so each part caches, refetches and invalidates on its own schedule.

FAQ

What is the difference between staleTime and gcTime in TanStack Query?

staleTime controls how long data counts as fresh, so no background refetch happens during that window (default 0). gcTime controls how long unused data stays in the cache after the last component using it unmounts (default 5 minutes).

What is the difference between isPending, isLoading and isFetching?

isPending means there is no data yet. isFetching is true whenever a request is in flight, including background refetches. isLoading is isPending and isFetching together, meaning the first load is happening right now.

Is TanStack Query the same as React Query?

Yes. React Query was renamed TanStack Query when it became framework-agnostic. The React package is @tanstack/react-query, and version 5 is the current major version.

Do I still need TanStack Query with React Server Components?

Often less, but still useful. Server Components handle initial data well, while TanStack Query shines for client-side interactivity: mutations, optimistic updates, polling, infinite scroll and data shared across many client components.

Wrapping up

TanStack Query turns server data into a cache you configure instead of code you maintain. Get the query keys right, pick a sensible staleTime, invalidate after mutations, and add optimistic updates where speed matters. If you are deciding how it fits with Zustand or Redux, read React state management in 2026.

  • #react
  • #tanstack-query
  • #data-fetching
  • #caching