EthicsComputerCourses
Join training +91 90532 10052

Server State with TanStack Query

React · Lesson 27 of 41 Advanced 8 min read Updated

In Data Fetching Patterns you fetched data inside an effect and met the problems that appear as an app grows: the same data is downloaded again on every visit, two components fetch the same thing twice, nothing retries after a network blip, and data goes out of date while the tab sits open. Server state, data that really lives on a server, needs a cache, and TanStack Query (@tanstack/react-query, version 5) is the most widely used cache for React apps.

In this lesson you will learn how a query cache thinks, build a tiny one to see the idea run, and then use TanStack Query for real: queries, keys, freshness settings, pagination, mutations, cache invalidation and optimistic updates.

How a query cache thinks

A query cache stores each response under a query key, an array such as ["products", { page: 2 }]. Every component asking for the same key shares one request and one cached result. Each cached entry is either:

  • fresh: recent enough to use without asking the server again, or
  • stale: still shown immediately, but refetched in the background when a good moment comes.

This "show the old data now, refresh quietly" strategy is called stale-while-revalidate. It is why apps built with a query cache feel instant when you go back to a page. This runnable example builds a miniature cache with useSyncExternalStore so you can watch the idea work. Each product's data stays fresh for 4 seconds:

React JSXTry it yourself
import { useEffect, useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";

const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const prices = { Saree: 18500, Kurta: 1299 };

async function fetchProduct(name) {
  console.log(`Network: fetching ${name}`);
  await wait(800);
  return `${name}: ₹${prices[name].toLocaleString("en-IN")}`;
}

// The cache: key -> { data, fetchedAt, fetching }
const cache = new Map();
const listeners = new Set();
const subscribe = (l) => (listeners.add(l), () => listeners.delete(l));
function update(key, changes) {
  cache.set(key, { ...cache.get(key), ...changes }); // a new object, so React notices
  listeners.forEach((l) => l());
}

function useLiteQuery(key, fetcher, staleTime) {
  const entry = useSyncExternalStore(subscribe, () => cache.get(key));
  useEffect(() => {
    const current = cache.get(key);
    const isStale = !current || Date.now() - current.fetchedAt > staleTime;
    if (isStale && !current?.fetching) {
      update(key, { fetching: true });
      fetcher().then((data) => update(key, { data, fetchedAt: Date.now(), fetching: false }));
    }
  }, [key]);
  return { data: entry?.data, isPending: !entry?.data, isFetching: Boolean(entry?.fetching) };
}

function Product({ name }) {
  const { data, isPending, isFetching } = useLiteQuery(name, () => fetchProduct(name), 4000);
  if (isPending) return <p>Loading {name}...</p>;
  return <p>{data} {isFetching && <small>(refreshing...)</small>}</p>;
}

function Shop() {
  const [name, setName] = useState("Saree");
  return (
    <>
      {Object.keys(prices).map((n) => <button key={n} onClick={() => setName(n)}>{n}</button>)}
      <button onClick={() => (prices.Saree += 500)}>Server raises saree price</button>
      <Product name={name} />
    </>
  );
}

createRoot(document.getElementById("root")).render(<Shop />);

Load the example, click "Kurta", then quickly click "Saree" again. The console shows only two network requests:

Output
Network: fetching Saree
Network: fetching Kurta

The saree appears instantly the second time, because its entry is still fresh. Now click "Server raises saree price" and "Kurta", wait more than 4 seconds, and click "Saree". The old price appears at once with "(refreshing...)", a new request for the saree runs, and the new price replaces the old one. Notice the two flags: isPending means "no data yet, show a loader", while isFetching means "a request is running", which may happen while old data is on screen. TanStack Query uses the same names.

A real cache must also handle errors, retries, cancelling, removing unused entries and much more. That is what the library is for.

Setting up TanStack Query

Terminal
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools

Create one QueryClient for the whole app (outside any component, so it is not recreated on every render) and provide it at the top:

React JSX
// src/main.jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 60 * 1000 } }, // fresh for one minute
});

createRoot(document.getElementById("root")).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools />
  </QueryClientProvider>
);

The DevTools panel lists every query, its key, its state (fresh, stale, fetching, inactive) and its data. It is only included in development builds. These defaults explain most "why did it refetch?" questions:

SettingDefaultMeaning
staleTime0Data is stale immediately, so it is refetched on the next trigger
gcTime5 minutesUnused cache entries are deleted after this time
retry3Failed queries retry three times, with growing delays, before showing an error
refetchOnWindowFocustrueStale queries refetch when the user returns to the tab
refetchOnMount, refetchOnReconnecttrueStale queries refetch when a new component uses them or the network returns

A staleTime of a minute or more is a sensible starting point for data that does not change every second, such as a product catalogue.

Queries, keys and pagination

Put every value the request depends on into the key. When the key changes, TanStack Query fetches the new data and caches each key separately. A queryOptions object keeps the key and the function together, so every place that needs this data uses exactly the same key:

React JSX
import { queryOptions, useQuery, keepPreviousData } from "@tanstack/react-query";

async function getJson(url, signal) {
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`Server answered ${res.status}`); // a thrown error means "failed"
  return res.json();
}

export const productsQuery = (page) =>
  queryOptions({
    queryKey: ["products", { page }],
    queryFn: ({ signal }) => getJson(`/api/products?page=${page}`, signal),
    staleTime: 5 * 60 * 1000,
  });

function ProductTable() {
  const [page, setPage] = useState(1);
  const { data, isPending, isError, error, isFetching, isPlaceholderData } = useQuery({
    ...productsQuery(page),
    placeholderData: keepPreviousData, // keep showing page 1 while page 2 loads
  });

  if (isPending) return <p>Loading products...</p>;
  if (isError) return <p role="alert">Could not load products: {error.message}</p>;
  return (
    <>
      <ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data.items.map((p) => <li key={p.id}>{p.name}: ₹{p.price}</li>)}
      </ul>
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>Previous</button>
      <button onClick={() => setPage((p) => p + 1)} disabled={isPlaceholderData || !data.hasMore}>Next</button>
      {isFetching && <small> Updating...</small>}
    </>
  );
}

The queryFn receives a signal that TanStack Query aborts when the result is no longer needed, and it must throw on failure; returning an error object would be cached as if it were good data. Other useful options: enabled: Boolean(userId) waits until a value exists before fetching, and useSuspenseQuery works with <Suspense> boundaries instead of isPending checks. Because productsQuery(2) is a plain object, you can also warm the cache before the user clicks: queryClient.prefetchQuery(productsQuery(2)), or call queryClient.ensureQueryData(...) from a React Router loader.

Mutations and invalidation

Reading is a query; creating, updating or deleting is a mutation. After a mutation succeeds, the cached lists are out of date, so you invalidate them: TanStack Query marks every query whose key starts with the given key as stale and refetches the ones on screen:

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

function AddProductForm() {
  const queryClient = useQueryClient();
  const addProduct = useMutation({
    mutationFn: (product) =>
      fetch("/api/products", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(product),
      }).then((res) => {
        if (!res.ok) throw new Error("Could not save the product");
        return res.json();
      }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["products"] }), // every page
  });

  function handleSubmit(formData) {
    addProduct.mutate({ name: formData.get("name"), price: Number(formData.get("price")) });
  }

  return (
    <form action={handleSubmit}>
      <input name="name" aria-label="Product name" />
      <input name="price" type="number" aria-label="Price" />
      <button disabled={addProduct.isPending}>{addProduct.isPending ? "Saving..." : "Add"}</button>
      {addProduct.isError && <p role="alert">{addProduct.error.message}</p>}
      {addProduct.isPending && <p style={{ opacity: 0.5 }}>{addProduct.variables.name} (saving)</p>}
    </form>
  );
}

Two details matter. Returning the promise from onSuccess keeps the mutation pending until the lists have been refetched, so the button does not re-enable before the new product is visible. And addProduct.variables holds what you passed to mutate, which gives you a simple optimistic update: show the new item, faded, while it saves. If the request fails, the faded item disappears and the error shows. For more complex cases, onMutate can write the expected result into the cache directly and roll it back in onError.

Common mistakes

  • Copying query data into useState. It stops updating when the cache refreshes. Use data directly and derive values from it.
  • Keys that miss a dependency. ["products"] for every page shows page 1's data on page 2. Put every input into the key.
  • Not throwing on HTTP errors. fetch resolves on 404 and 500, so check res.ok and throw.
  • Creating the QueryClient inside a component. The cache is thrown away on every render.
  • Forgetting to invalidate after a mutation. The list keeps showing old data until something else refetches it.
  • Using it for client state. A theme or an open menu is not server data; keep it in React state or a store, as in Choosing State Management.

Key points

  • Server data is a cache: TanStack Query stores it by query key and shares it between components.
  • Fresh data is used as is; stale data is shown immediately and refetched in the background.
  • Defaults: staleTime 0, gcTime 5 minutes, 3 retries, refetch on focus, mount and reconnect.
  • isPending means no data yet; isFetching means a request is running, possibly behind old data.
  • queryOptions keeps keys and fetch functions together for useQuery, prefetching and loaders.
  • useMutation changes data; invalidateQueries marks related queries stale so they refetch.

Exercise

Extend the mini cache example: add a "Refresh" button inside Product that forces a new request for the current product even if it is fresh, and show the time of the last fetch under the price (use toLocaleTimeString("en-IN")). Hint: give useLiteQuery a returned refetch function that runs the same fetching code without the freshness check.

Show solution
React JSX
import { useEffect, useState, useSyncExternalStore } from "react";
import { createRoot } from "react-dom/client";

const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const prices = { Saree: 18500, Kurta: 1299 };
async function fetchProduct(name) {
  console.log(`Network: fetching ${name}`);
  await wait(800);
  return `${name}: ₹${prices[name].toLocaleString("en-IN")}`;
}

const cache = new Map();
const listeners = new Set();
const subscribe = (l) => (listeners.add(l), () => listeners.delete(l));
function update(key, changes) {
  cache.set(key, { ...cache.get(key), ...changes });
  listeners.forEach((l) => l());
}

function useLiteQuery(key, fetcher, staleTime) {
  const entry = useSyncExternalStore(subscribe, () => cache.get(key));
  function refetch() {
    if (cache.get(key)?.fetching) return;
    update(key, { fetching: true });
    fetcher().then((data) => update(key, { data, fetchedAt: Date.now(), fetching: false }));
  }
  useEffect(() => {
    const current = cache.get(key);
    if (!current || Date.now() - current.fetchedAt > staleTime) refetch();
  }, [key]);
  return { data: entry?.data, fetchedAt: entry?.fetchedAt, isFetching: Boolean(entry?.fetching), refetch };
}

function Product({ name }) {
  const { data, fetchedAt, isFetching, refetch } = useLiteQuery(name, () => fetchProduct(name), 4000);
  if (!data) return <p>Loading {name}...</p>;
  return (
    <div>
      <p>{data} {isFetching && <small>(refreshing...)</small>}</p>
      <small>Last fetched at {new Date(fetchedAt).toLocaleTimeString("en-IN")}</small>{" "}
      <button onClick={refetch}>Refresh</button>
    </div>
  );
}

function Shop() {
  const [name, setName] = useState("Saree");
  return (
    <>
      {Object.keys(prices).map((n) => <button key={n} onClick={() => setName(n)}>{n}</button>)}
      <Product name={name} />
    </>
  );
}

createRoot(document.getElementById("root")).render(<Shop />);

refetch is the fetching code on its own, and the effect calls it only when the entry is missing or stale. The button calls it directly, which is what TanStack Query's own refetch() does.

Quick Quiz

1. Two components on the same screen call useQuery with the key ["user", 7]. How many requests are sent?

2. With the default staleTime of 0, what happens when a user returns to a page whose data is cached?

3. What is the difference between isPending and isFetching?

4. After adding a product with useMutation, how do you update the product lists on screen?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call