Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~34 min· topic 6 of 7

Topic 14.6

Server State with TanStack Query

In one line

TanStack Query keeps server data in a shared cache identified by query keys: useQuery reads it (with isPending, isFetching and error), staleTime decides when data needs refreshing and gcTime when unused data is deleted, and useMutation plus invalidateQueries change data on the server and refresh every screen that shows it.

Think of it like this

A school notice board. Instead of every student phoning the office to ask "when is the trip?", the office pins the answer on the board. Everyone reads the board. Each notice has a title (the key) and a date: if it's recent, you trust it; if it's old, someone checks with the office and pins a fresh copy, while you keep reading the old one. When plans change, the office marks the notice "out of date" and replaces it. TanStack Query is that notice board for your app's server data.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Server state
Your app's copy of data that a server owns; it can change elsewhere and go out of date.
QueryClient
The object that holds TanStack Query's cache and settings. One per app.
Query key
An array that names a piece of server data, like ["user", 7]. Same key, same cache entry.
Query function
Your function that returns a promise of the data, such as a fetch call.
Stale
Data that may be out of date. It's still shown, but it gets refetched in the background.
staleTime
How long fetched data counts as fresh (no refetch). Default 0.
gcTime
How long unused data stays in the cache before it's deleted. Default 5 minutes.
Background refetch
Fetching new data while still showing the cached data.
Mutation
A request that changes data on the server, run with useMutation.
Invalidate
Mark cached queries as stale so they refetch, usually after a mutation.

Step by step

01Install and provide the client

One client for the whole app, created outside components (creating it during render would make a new empty cache on every render).

main.jsxwhole filejsx
// npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

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

02Read data with useQuery

Name the data with a key that includes every input, give a function that returns a promise, and render by status.

User.jsxwhole filejsx
function User({ id }) {
  const { data, isPending, isError, error, isFetching } = useQuery({
    queryKey: ["user", id],
    queryFn: () => fetch(`/api/users/${id}`).then((r) => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`); // fetch doesn't reject on 404/500
      return r.json();
    }),
  });
  if (isPending) return <Skeleton />;
  if (isError) return <p role="alert">{error.message}</p>;
  return <h2>{data.name} {isFetching && <small>updating</small>}</h2>;
}

03Understand the life of a cache entry

Fresh for staleTime, then stale (shown, refetched on triggers). When the last component using it unmounts, it becomes inactive, and after gcTime it's deleted.

Understand the life of a cache entrydiagram
Rendering diagram…

04Tune staleTime per query

The first live example mounts two users of the same data with different staleTime, hides them, and shows them again. The stale one refetches in the background; the fresh one doesn't.

defaults.jswhole filejsx
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,   // trust data for 1 minute
      gcTime: 5 * 60_000,  // forget unused data after 5 minutes (the default)
    },
  },
});

05Change data with a mutation, then invalidate

The mutation calls the server. On success, invalidate the affected keys; every component using them refetches and updates. The second live example shows the order of requests.

Change data with a mutation, then invalidatediagram
Rendering diagram…

06Add the DevTools while developing

The panel shows each key, its state and its data. It's excluded from production builds automatically.

App.jsxwhole filejsx
// npm install -D @tanstack/react-query-devtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

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

Try it yourself

  1. 1

    Make both users fresh

    In the first example, change the stale user's staleTime to 60_000. Predict how many server: lines you'll see now. Then set both to 0 and remove label from the query key: how many requests on the first load? (Same key, so they share one.)

  2. 2

    Update the cache without a refetch

    In the mutation example, replace the invalidateQueries call with queryClient.setQueryData(["todos"], (old) => [...old, title]) using the mutation's onSuccess: (title) => ... argument. Predict whether a second GET /todos appears.

  3. 3

    Show isFetching during the retry

    In the stats example, change retry to 3 and retryDelay to 100. Predict how many attempts happen before success, and whether the error ever shows.

Code & diagrams

useQuery: caching, staleTime and background refetch New tab

Shown again, both have data instantly from the cache, with no loading state. The stale one refetches in the background (call 3) and updates; the fresh one makes no request.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

server: fetch user 1 (call 1)
server: fetch user 1 (call 2)
first render: stale at once: Loading... | fresh 1 min: Loading...
loaded: stale at once: Asha from call 1 | fresh 1 min: Asha from call 2
server: fetch user 1 (call 3)
shown again: stale at once: Asha from call 1 (refreshing) | fresh 1 min: Asha from call 2
after refetch: stale at once: Asha from call 3 | fresh 1 min: Asha from call 2
useMutation, invalidateQueries and deduplication New tab

Two components, one GET: requests for the same key are deduplicated. After the successful POST, invalidation triggers one new GET and both components update. The failed mutation leaves the cache alone.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

server: GET /todos
loaded: 1 todos | Learn Suspense
server: POST /todos ""
error: Title is required
server: POST /todos "Learn TanStack Query"
while saving: Adding...
server: GET /todos
after invalidate: 2 todos | Learn Suspense, Learn TanStack Query
status vs fetchStatus, retries and refetch New tab

The first failure is retried automatically after 200 ms (`failures: 1` while still pending). After the retry fails, status becomes `error`. Try again refetches; with no data in the cache, the query goes back to `pending` and then `success`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: pending / fetching / failures: 0
server: GET /stats, attempt 1
render: pending / fetching / failures: 1
server: GET /stats, attempt 2
render: error / idle / failures: 2
server: GET /stats, attempt 3
render: pending / fetching / failures: 0
render: success / idle / failures: 0
Query keys, prefix invalidation and setQueryDatajsx
// Keys from general to specific: easy to invalidate a whole group.
const todoKeys = {
  all: ["todos"],
  list: (filters) => ["todos", "list", filters],
  detail: (id) => ["todos", "detail", id],
};

useQuery({ queryKey: todoKeys.list({ status: "done" }), queryFn: () => getTodos("done") });
useQuery({ queryKey: todoKeys.detail(7), queryFn: () => getTodo(7) });

// After editing todo 7:
queryClient.setQueryData(todoKeys.detail(7), updatedTodo);          // write the server's answer directly
queryClient.invalidateQueries({ queryKey: ["todos", "list"] });      // every list, any filters
queryClient.invalidateQueries({ queryKey: todoKeys.all });           // everything about todos
queryClient.invalidateQueries({ queryKey: ["todos"], exact: true }); // only the key ["todos"] itself

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Leave an input out of the query key

Write useQuery({ queryKey: ["user"], queryFn: () => fetchUser(id) }) and switch id from 1 to 2.

terminal
$ Preview
── what you'll see ──
Asha
(still Asha after choosing user 2: no new request is made)

Break #2

Create the QueryClient inside a component

Move const queryClient = new QueryClient(); inside App and make App re-render (for example, from a state change).

terminal
$ Console
── what you'll see ──
server: GET /todos
server: GET /todos
server: GET /todos
(a new request and a loading state after every App render)

Myth vs fact

Myth

TanStack Query is a fetching library like axios.

Fact

It doesn't fetch anything: you give it any function returning a promise. It manages caching, deduplication, refetching, retries and state around that function.

Myth

staleTime and gcTime are the same thing.

Fact

staleTime decides when cached data needs a background refetch. gcTime decides when unused data is deleted. Data can be stale yet still cached for minutes.

Myth

A stale query shows a loading spinner again.

Fact

Stale data is still shown immediately; the refetch happens in the background (isFetching), and isPending stays false.

Myth

With TanStack Query you no longer need any client state tool.

Fact

It replaces the server-data part of global state. UI state (open panels, form drafts, theme) still lives in useState, context or a small store (Topic 15.6).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    fetch only rejects on network failure, not on HTTP errors, so a query function must check res.ok and throw; otherwise a 500 page becomes data. Query also passes an AbortSignal in the function's context (queryFn: ({ signal }) => fetch(url, { signal })), and it cancels the request if the query is no longer needed.

  • ▸

    Query uses structural sharing: after a refetch, unchanged parts of the new data keep the same object identity as before, so memo components and effect dependencies don't see changes that aren't real. Components also only re-render for the result fields they actually read (tracked properties).

  • ▸

    Window-focus refetching surprises people in development (switching to DevTools and back triggers refetches). It's a feature for real users; tune it per query with refetchOnWindowFocus or by raising staleTime rather than turning it off globally.

  • ▸

    For server rendering and render-as-you-fetch, prefetch on the server or in a route loader with queryClient.prefetchQuery() / ensureQueryData(), then pass the cache to the client with dehydrate and HydrationBoundary; useQuery then starts with data.

Remember this

  1. 1

    Server state is different. Client state (is the menu open, what's typed in a field) belongs to your app alone. Server state is a copy of data owned by a server: it can change without you knowing, several screens need the same piece, and it goes stale (out of date). Keeping it in useState + effects means writing caching, deduplication, refreshing and race handling yourself (Topics 14.2 and 14.7). TanStack Query (formerly React Query, version 5 today, @tanstack/react-query) is a library that does all of it. It isn't a fetching tool: you bring any function that returns a promise.

  2. 2

    **Setup and useQuery.** Create one QueryClient (it holds the cache) and wrap the app in <QueryClientProvider client={queryClient}>. In a component: const { data, error, isPending, isFetching } = useQuery({ queryKey: ["user", id], queryFn: () => fetchUser(id) }). The query key is an array that names the data: the same key anywhere in the app means the same cache entry. Put every input the function uses in the key (["todos", { status: "done", page: 2 }]): when a key value changes, it's a different entry and Query fetches it. Keys are compared by value (object property order doesn't matter), and two components asking for the same key at once share one request (deduplication).

  3. 3

    The states. status is about data: "pending" (no data yet), "error" (the last fetch failed; data from an earlier success may still be there), "success" (data is there). fetchStatus is about the request: "fetching", "paused" (offline) or "idle". They combine: success + fetching is a background refetch, where you keep showing data with a small hint. Shortcuts: isPending, isError, isSuccess, isFetching, and isLoading (= isPending && isFetching, the very first load). Use isPending for the full loading UI and isFetching for a subtle "refreshing" indicator. Failed queries retry 3 times by default with exponential backoff (retry, retryDelay).

  4. 4

    **staleTime vs gcTime. These two settings confuse everyone, so learn them precisely. staleTime (default 0) is how long data counts as fresh. Fresh data is served from the cache with no request. Stale data is still shown instantly, but Query refetches it in the background on certain triggers: a new component mounts using it, the window regains focus, the network reconnects** (refetchOnMount, refetchOnWindowFocus, refetchOnReconnect, all true by default), or you call refetch(). **gcTime ("garbage collection time", default 5 minutes**, called cacheTime in v4) is how long an unused entry (no component is using it) stays in the cache before it's deleted. So: staleTime decides when to refetch, gcTime decides when to forget. Raising staleTime (say to 60 s for data that rarely changes) is the most common tuning.

  5. 5

    Mutations and invalidation. Reading is a query; changing server data (create, update, delete) is a mutation: const add = useMutation({ mutationFn: (title) => api.addTodo(title), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["todos"] }) }), then add.mutate("Buy milk"). The result has isPending, isError, error, data and reset. Invalidating marks matching queries as stale and refetches the ones on screen, so every component that shows todos updates. Matching is by prefix: ["todos"] matches ["todos", 1] and ["todos", { page: 2 }]. Alternatives: write the server's answer straight into the cache with queryClient.setQueryData(key, updater), or update optimistically in onMutate and roll back in onError.

  6. 6

    DevTools and more. @tanstack/react-query-devtools adds a floating panel (development only) that lists every query key with its status (fresh, stale, fetching, inactive), its data and buttons to refetch or invalidate. Other features you'll meet: enabled: false to wait for an input (dependent queries), placeholderData: keepPreviousData for pagination without flashing, useInfiniteQuery for "load more" lists (Topic 24.2), useSuspenseQuery for Suspense (Topic 14.3), prefetchQuery to load before navigation, and throwOnError to send errors to an error boundary (Topic 14.5). Redux users have the same ideas in RTK Query (Topic 15.4).

Explain it without notes

01

What is the difference between server state and client state, and why does server state need a special tool?

02

Explain staleTime and gcTime and what happens when a component mounts with cached data in each case.

03

What is a query key and what rules should it follow?

04

Walk through what happens when a mutation succeeds and you call invalidateQueries.

05

What's the difference between isPending, isLoading and isFetching?

Practice

01

Write a useProduct(id) hook with TanStack Query that caches each product for 5 minutes as fresh and doesn't run when id is null.

02

Write a DeleteTodo button using useMutation that removes the todo from the ["todos"] cache with setQueryData on success.

03

A search page queries ["search", term]. Each keystroke shows a loading state. Make it keep the previous results while the next ones load.

Trade-offs

  • ↔

    TanStack Query removes a lot of hand-written loading, caching and refetch code, at the cost of a dependency and a cache model (keys, stale vs gc, invalidation) the whole team must understand.

  • ↔

    Low staleTime keeps data current but makes more requests (on every mount and focus); high staleTime saves requests but can show outdated data. Choose per query based on how often the data really changes.

  • ↔

    Invalidating refetches from the server (always correct, one more request); setQueryData updates instantly with no request but you must reproduce the server's logic (sorting, computed fields) correctly.

Done when you can

  • I can set up a QueryClient and read data with useQuery.

  • I put every input of the query function into the query key.

  • I can explain staleTime vs gcTime and background refetching.

  • I can tell status from fetchStatus and pick isPending vs isFetching.

  • I can write a mutation that invalidates or updates the cache.

  • I can add the Query DevTools in development.