Home / Articles / TanStack Query for React: Caching, Refetching, and Mutations

This article is published in English.

TanStack Query for React: Caching, Refetching, and Mutations

Replace useEffect fetch boilerplate with TanStack Query: query keys, staleTime, gcTime, mutations, and when the library is unnecessary.

1840 words

How asynchronous server state is cached, refreshed, and updated — and when the library is worth adding.

Many React apps begin with the same data-loading shape: a useEffect, a fetch, and a handful of useState flags for pending and error UI. That pattern is correct for a first cut. It is also where duplicate requests, stale screens, and copy-pasted boilerplate tend to appear.

The sections below walk through those gaps and show how TanStack Query fills them. The only prerequisites are React hooks and basic TypeScript. Examples call DummyJSON, a public API that does not require an API key.

1. The baseline pattern

A product list written with the usual hooks looks like this.

import { useEffect, useState } from "react";
type Product = {
  id: number;
  title: string;
  price: number;
};
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    setIsLoading(true);
    fetch("https://dummyjson.com/products?limit=10")
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data: { products: Product[] }) => {
        if (!cancelled) setProducts(data.products);
      })
      .catch((err: Error) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>{error}</p>;
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.title} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

That listing is already careful: it uses a cancelled flag so a slow response cannot update state after unmount. Plenty of real codebases omit that guard.

2. What this code does not handle

The fetch itself is fine. The problems sit around it.

  • No cache. Leave the route and return, and the network call runs again even when the payload has not changed.
  • No request deduplication. Three components that need the same product list fire three identical requests.
  • No retry. A single dropped connection becomes an error UI, even when a second attempt would succeed.
  • No revalidation. A tab left open for an hour keeps showing hour-old data until something else triggers a load.
  • Race conditions. When an input such as a search term changes quickly, an older response can overwrite a newer one. The cancelled flag helps on unmount; it does not fully solve overlapping in-flight requests.
  • Repeated boilerplate. The same loading, error, and cleanup wiring is duplicated in every data-loading component.

Each gap has a known fix. Implementing those fixes by hand means building a caching layer yourself.

3. The idea behind the library

A useful split is between two kinds of state.

Client state is owned by the UI: whether a modal is open, the current form field, the selected theme. It changes only when your code changes it. useState fits that job.

Server state is borrowed. It lives in a store you do not control. Other users can change it, and the copy in the browser is only a snapshot. Keeping that snapshot only in useState pretends a temporary view is authoritative.

Borrowed data needs a dedicated store: a place for the copy, an age signal, and a rule that decides when to pull again.

A refrigerator is a fair analogy. Milk stays at home so you are not walking to the shop for every cup of coffee, yet it expires, so you check the date and restock before it spoils. TanStack Query plays that role for API responses.

4. What TanStack Query is

TanStack Query manages asynchronous remote state in browser apps. The project is MIT-licensed, free to use, and common across React codebases.

Guides written years ago still say React Query. That label lasted through version 3. Version 4 renamed the project to TanStack Query after adapters for Vue, Svelte, Solid, and Angular shipped. On React you still install @tanstack/react-query; major version 5 is current.

It is not a substitute for fetch or axios. The request function remains yours. Around that function, the library decides timing, storage, freshness, and failure handling.

5. Setup

Two steps are enough to start.

npm install @tanstack/react-query

Then wrap the tree once at the root:

// main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";

const queryClient = new QueryClient();

export default function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

QueryClient is the cache instance. QueryClientProvider exposes it to every descendant component.

6. The same component, rewritten

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

type Product = {
  id: number;
  title: string;
  price: number;
};

async function fetchProducts(): Promise<Product[]> {

  const res = await fetch("https://dummyjson.com/products?limit=10");

  if (!res.ok) throw new Error("Request failed");
  const data: { products: Product[] } = await res.json();
  return data.products;
}

function ProductList() {

  const { data, isPending, isError, error } = useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
  });

  if (isPending) return <p>Loading…</p>;
  if (isError) return <p>{error.message}</p>;
  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>
          {product.title} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

Roughly forty lines shrink to about fifteen. data is typed as Product[] without an extra annotation because the type flows from fetchProducts. After the isPending and isError branches, TypeScript treats data as defined, so there is no optional chaining on map and no non-null assertion.

7. What the shorter version gives you

Compared with the gaps in section 2, the defaults already cover the common cases:

  • Remount shows the cached payload immediately and revalidates in the background.
  • Identical in-flight queries collapse into a single request.
  • Failed requests retry automatically (three attempts by default, with backoff).
  • Stale entries refetch on window focus, network reconnect, and remount.
  • Only the latest response for a key is written into the cache, which limits race damage.
  • One hook replaces the manual loading and error state.

None of that behaviour was configured in the rewritten component. It is the library default.

8. Three things worth understanding

Most early confusion traces back to the next three ideas.

The query key

queryKey is the cache address. Two components that both use ["products"] share one entry and one network call.

Rule of thumb: every value the query function depends on must appear in the key.

function ProductList({ category }: { category: string }) {
  const { data } = useQuery({
    queryKey: ["products", category],
    queryFn: () => fetchProductsByCategory(category),
  });
  // …
}

If category is omitted from the key, changing category can still show the previous category's cached list. That mistake is extremely common for first-time users.

staleTime and gcTime

The names sound alike and mean different things.

staleTime sets the freshness window. Inside that window the library skips network work. With the default of 0, a result is immediately stale: the UI can still render the cached value, yet any trigger queues a background refresh. Raise the value when the payload rarely changes:

useQuery({
  queryKey: ["products"],
  queryFn: fetchProducts,
  staleTime: 5 * 60 * 1000, // fresh for five minutes
});

gcTime is how long unused data remains in memory after the last subscriber unmounts. The default is five minutes. When that window ends, the entry is removed and the next visit starts cold.

In short: staleTime governs refetching; gcTime governs deletion.

When refetching happens

By default, a stale query refetches when a component mounts, when the window regains focus, and when the network reconnects. Each behaviour can be turned off on the client or on a single query:

useQuery({
  queryKey: ["products"],
  queryFn: fetchProducts,
  refetchOnWindowFocus: false,
});

Refetch-on-focus surprises people the first time they see it. Keeping it enabled is usually why a long-lived tab stays current.

9. Changing data with useMutation

useQuery reads. useMutation writes.

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

type NewProduct = {
  title: string;
  price: number;
};

function AddProductButton() {

  const queryClient = useQueryClient();

  const { mutate, isPending } = useMutation({

    mutationFn: async (product: NewProduct) => {
      const res = await fetch("https://dummyjson.com/products/add", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(product),
      });

      if (!res.ok) throw new Error("Could not add product");
      return res.json();
    },

    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["products"] });
    },
  });

  return (
    <button
      onClick={() => mutate({ title: "New product", price: 25 })}
      disabled={isPending}
    >
      {isPending ? "Saving…" : "Add product"}
    </button>
  );
}

The line that matters is invalidateQueries. That call flags every cache entry under the ["products"] prefix as out of date, so mounted observers request fresh data immediately. Local arrays are not patched by hand, and the page does not need a full reload.

10. The devtools

npm install @tanstack/react-query-devtools

import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

<QueryClientProvider client={queryClient}>
  <App />
  <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

In development, a panel lists every query key, status, payload, and last fetch time. Watching entries move between fresh and stale while you click around teaches the cache model faster than reading alone. The package is stripped from production builds automatically.

Common mistakes

  • Leaving variables out of the query key. If the query function reads a value, the key must include it.
  • Putting useQuery inside a useEffect. The hook already runs during render; there is nothing extra to "trigger."
  • Using the library for pure client state. Form fields and modal flags belong in useState.
  • Setting staleTime: Infinity everywhere. That turns off revalidation, which removes most of the benefit.
  • Copying data into local state. You then maintain two copies, and the rendered one stops tracking the cache.

12. When you may not need it

If the app hits a single endpoint on a single screen, the provider and hooks can be more ceremony than the problem warrants.

If the framework already supplies a data layer — Next.js server components, or a router with loaders — part of the work is already done. TanStack Query can still help for interactive client fetches, but it is not mandatory.

For state that never leaves the browser, choose a different tool.

13. Where to go next

Day-to-day work is covered by the concepts above. Deeper topics live elsewhere:

  • Official documentation — reference material and interactive demos
  • Paged and infinite lists — reach for useInfiniteQuery when scrolling loads more pages
  • Queries that wait on others — gate a follow-up request with enabled until a prerequisite finishes
  • Optimistic UI — render the intended result before the mutation response returns

Carry one framing forward: remote data belongs in a cache, not in ad-hoc component state. With that model in mind, the rest of the API tends to make sense.