cf.completefrontend
Open lab My progress
THE REACT FIELD GUIDE

Data fetching libraries

What TanStack Query and SWR add to React: a cache keyed by query, shared requests, stale data refetched in the background, and updates after a change.

By the end, you can
  • 01
    Tell server state from client stateSpot data that is only a copy of what a server holds, and say why it needs loading, sharing, and refreshing.
  • 02
    Explain what a query cache doesFollow how a cache keyed by the query shares one request, shows a stale copy at once, and asks again in the background.
  • 03
    Change server data safelyInvalidate the copies a change makes old, update the cache optimistically and roll back, with TanStack Query or SWR.

One product, asked for again and again

In Fetching data you loaded a product in an effect, with a cleanup, a loading state, and an error state. Here that code is moved into a custom hook, useProduct, and two parts of a product page use it: the title and a stock badge. The server is pretend, in the api.js tab: it answers after 0.8 seconds, nothing goes over the network, and other shoppers buy a tea every 4 seconds, so the stock goes down. Open the console under the result.

Two components, each fetching the same productPop out in the code editor (opens in a new tab)JSX
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; function useProduct(id) {  const [product, setProduct] = useState(null);  useEffect(() => {    let ignore = false;    fetchProduct(id).then((data) => {      if (!ignore) setProduct(data);    });    return () => {      ignore = true;    };  }, [id]);  return product;} function ProductTitle() {  const product = useProduct("tea");  return <h2>{product ? product.name : "Loading…"}</h2>;} function StockBadge() {  const product = useProduct("tea");  return <p>{product ? `${product.stock} left` : "Loading…"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
ResultLoading…

Lines 4 to 16 are the effect from the last lesson: state for the product, a request, and an ignore flag in the cleanup. ProductTitle calls the hook on line 19, and StockBadge calls it again on line 24. The console says Request 1 to Request 4: each component sends its own request, and StrictMode runs each new effect twice in development, so two components make four. In production it would be two, for one product.

Now press Hide the product, then Show the product. Both lines say Loading… for 0.8 seconds again, and the console counts four more requests. Hiding removed the components, and their state went with them, so the product you just saw is gone. A real app has this everywhere: a header, a list, and a details page all asking for the same things, and every visit starting from nothing.

Two windows. The server window holds the real stock of green tea: 7 left. The page window holds a copy that says 9 left, fetched 8 seconds ago, so it is stale. Below it, the page also holds tab: reviews, which only the page has.serverGreen tea: 7 leftthe real onea request copies itpageGreen tea: 9 leftserver state: a copy, 8 s oldtab: "reviews"client state: only hereOther shoppers buythe server's number changes
The page's product is a copy of the server's, made when it asked. Other shoppers kept buying, so the copy says 9 left while the server says 7 left. The tab you picked has no copy anywhere else: it's client state.

The picture shows why this keeps happening. The product isn't the page's data at all. The server holds it, and the page holds a copy from the moment it asked. That copy needs work that useState doesn't do: sharing it between components, keeping it after they leave, and asking again when it may be old. A data fetching library, such as TanStack Query or SWR, does that work for every request in the app.

Real-life analogyA library's catalogue of borrowed books

A town library doesn't buy a new copy of a book each time someone asks for it. It keeps one copy on the shelf, lends it to whoever asks, and orders a new edition when the old one is out of date. When the publisher sends a correction, the librarian marks the copy as old and orders a fresh one.

In real life: The publisher holds the real book, and prints new editions
In React: The server holds the real data, and it changes
In real life: The library keeps one copy of each title on a shelf
In React: The cache keeps one copy of each answer, under its key
In real life: Two readers asking for the same title get the same copy
In React: Two components asking for the same key share one request and one answer
In real life: The library checks for a new edition when a copy gets old
In React: The library refetches when a copy is stale
In real life: A correction slip makes the librarian order a new copy
In React: Invalidating after a change makes the library ask again
The main idea

Data from a server is server state: a copy, not yours to own. A data fetching library keeps those copies in one cache, named by a query key. Components that ask for the same key share one request and one answer. The library shows a saved copy at once, asks again when it may be old, and asks again when you tell it that something changed.

Server state is a copy you borrowed

React lessons so far kept data in state: a tab, a count, the text in a box. That's client state: it lives in this page, and only this page changes it. Data that comes from a server is different, and it has a name of its own, server state. Here are both in one component:

Client state and server state in one componentJSX
function ProductPage() {
  // Client state: only this page has it, and only you change it.
  const [tab, setTab] = useState("details");
  const [quantity, setQuantity] = useState(1);

  // Server state: a copy of what the server said, the last time you asked.
  const { data: product } = useQuery({
    queryKey: ["product", "tea"],
    queryFn: () => fetchProduct("tea"),
  });
  // …
}

Lines 3 and 4 are client state: the tab you picked and the quantity you chose. Nothing outside this page knows about them. Lines 7 to 10 are server state, read with a useQuery hook like the one you build in the next section. The product it gives back is a copy of what the server said the last time anyone asked. It's a fragment, so it isn't live; the rest of the lesson runs every piece of it.

How to read the table: each row is one question, answered for each kind of state.

Client state and server state
QuestionClient stateServer state
Where does it really live?In this page, in memory. Close the tab and it's gone.On a server. The page only holds a copy.
Who can change it?Only this page, when you call a set function.Anyone: other shoppers, other tabs, the shop's staff.
Can your copy be out of date?No. What you set is what there is.Yes. It's right when it arrives and may be old a minute later.
Does reading it take time?No. It's there when the component renders.Yes. The first read needs a request, a loading state, and an error state.
ExamplesAn open menu, the tab you picked, the text in a search box.A product's stock, your cart on the server, a list of orders.

Every "yes" in the server column is work: a loading state while you wait, an error state when it fails, and some way to tell when the copy is too old. Libraries for server state do that work. They don't replace useState: the tab and the quantity stay in useState, as before.

A toy cache: one entry per query key

The best way to see what a library does is to build a small one. The cache.js tab below is a toy: about forty lines, written for this lesson, with none of the care a real library takes. Don't use it in an app; use it to see the idea. It names its function useQuery and its options queryKey and queryFn, as TanStack Query does, so the real thing looks familiar later.

The product page on a toy cachePop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending } = useProduct("tea");  return <p>{isPending ? "Loading…" : `${data.stock} left`}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
ResultLoading…

In App.jsx, useProduct is now one call (lines 5 to 10). Line 7 gives the data a name, the query key: ["product", id]. Line 8 says how to get it. The console says Request 1: product tea and nothing more: two components, StrictMode, one request. Press Hide and Show: the product is back at once, with no Loading… and no request. Here is how, in the cache.js tab:

  • Line 4 makes the cache, a Map from keys to entries. It lives in the module, outside every component, so it stays when the components leave.
  • save on lines 13 to 16 replaces a key's entry with a new object and tells every listener. A new object each time is what useSyncExternalStore needs to see a change.
  • fetchQuery on lines 18 to 25 asks the server for a key. Line 19 returns early when that key already has a request going: that's deduplication, sharing one request between everyone who asks. Line 24 stores the request's promise in the entry, and lines 21 and 22 store the answer or the error when it settles.
  • In useQuery, line 28 turns the key into a string with JSON.stringify, so ["product", "tea"] from two components gives the same map key. Line 29 reads the entry with useSyncExternalStore, so the component renders again when its entry changes.
  • Lines 30 to 35 ask when a component appears without data for its key. The check is an Effect Event, so it reads the latest entry and queryFn while the effect only depends on key.
ProductTitle and StockBadge both call useQuery with the key product, tea. Arrows from both point at one cache entry under that key, holding Green tea, 9 left. One arrow from the entry goes to fetchProduct: one request.ProductTitleStockBadgecache["product", "tea"]Green tea, 9 leftone entry per keyfetchProduct1 requestsame key
The key ["product", "tea"] names one entry in the cache. Both components read that entry, so the first one to ask starts the request, and the second shares it. The answer goes into the entry, and both show it.

The key is the most important choice you make. Everything the request depends on goes in it: ["product", id], not ["product"], or the tea and the cake would share one entry, and exercise 2 shows what goes wrong then. TanStack Query's documentation gives the same rule: a key must be unique to the data it names.

One thing this cache gets wrong on purpose: wait 8 seconds, then Hide and Show. The badge still says 9 left. The copy is shown forever, and the server's stock never reaches the page again. The next section fixes that.

Stale data: show the copy, then ask again

A copy that might be old is stale. A good cache does two things with a stale copy: it shows it at once, because an old stock number beats a blank badge, and it asks the server again in the background. The new answer then replaces the copy. This is called stale-while-revalidate (revalidate means "check again"), and SWR takes its name from it. Step 2 of the toy cache adds it:

The toy cache, step 2: stale copies are asked for againPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending, isFetching } = useProduct("tea");  if (isPending) return <p>Loading…</p>;  return <p>{data.stock} left {isFetching && "(updating…)"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      <button onClick={() => window.dispatchEvent(new Event("online"))}>Back online</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
ResultLoading…

Wait about 5 seconds, then press Hide and Show. The badge shows 9 left (updating…) at once, and 0.8 seconds later 8 left. In the cache.js tab:

  • Line 21 notes when each answer arrived, in updatedAt.
  • Line 27 takes a staleTime: how many milliseconds a copy counts as fresh. It's 0 unless you say otherwise, so every copy is stale as soon as it arrives. TanStack Query uses the same default.
  • Lines 30 to 33 ask again when there's no copy, or when the copy's age on line 31 has reached staleTime. A request that's already going is still shared, by line 19.
  • Line 50 adds isFetching: true whenever a request is going, even with a copy on screen. Line 49's isPending stays true only while there's no copy at all. In App.jsx, line 19 shows Loading… only for isPending, and line 20 adds (updating…) for isFetching.
Three steps from left to right. First, Show: the page shows the stale copy, 9 left, marked updating. Then fetchProduct asks the server again, taking 800 milliseconds. Then the page shows the new answer, 8 left.page9 left (updating…)1. the copy, at onceisFetching: truefetchProduct2. ask again0.8 spage8 left3. the new answerisFetching: false
A stale copy is shown at once, with isFetching true, while the cache asks again in the background. When the answer arrives, it replaces the copy. The reader never sees Loading… again for data the page already had.

A copy can also go old while the page just sits there. Libraries ask again at two more moments, and lines 34 to 45 add both. Line 39 listens for visibilitychange, which the browser fires when you switch back to this tab, as TanStack Query does. Line 40 listens for online, which the browser fires when a lost connection comes back. Each time, line 36 checks that the page is visible, and the copy is asked for again if it's stale. Switch to another browser tab for a few seconds and come back: the badge says (updating…). You can't easily drop your connection, so line 28 of App.jsx has a Back online button that fires the same event the browser would.

Asking on every Show can be too much for data that rarely changes. Give the query a staleTime, and a copy is fresh, and used as it is, for that long:

A copy that stays fresh for ten secondsPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),    staleTime: 10000,  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending, isFetching } = useProduct("tea");  if (isPending) return <p>Loading…</p>;  return <p>{data.stock} left {isFetching && "(updating…)"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      <button onClick={() => window.dispatchEvent(new Event("online"))}>Back online</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
ResultLoading…

Line 9 adds staleTime: 10000. Hide and Show within ten seconds: no request, and no (updating…). After ten seconds, the next Show asks again. Choosing staleTime is choosing how old a copy may be before you care. A product description could be fresh for an hour; stock that changes every few seconds, not at all. The playground lets you watch the cache from the inside while you try each choice.

Playground: look inside the cache
The product pagenothing shown

No product on the page.

The cachestaleTime: 0

The cache is empty. Show a product.

No requests yet.

Try it yourself
How long a copy is fresh:
staleTime: 0

Every copy is stale the moment it arrives, as in TanStack Query by default. Show tea, Hide, Show again: the copy shows at once with (updating…), and the log has a new request. Coming back to the tab asks again too.

The toy cache from this section, rebuilt for this playground with the React on this page. The page shows a product with two components that read the same cache entry. Answers take 0.8 seconds, and the stock only changes when you press Someone buys a tea. Choosing a staleTime or pressing Reset starts with an empty cache and a full stock.

Pick staleTime: 0, then Show tea: one request, though two components read it. Hide and Show: stale, a new request, and the copy on screen meanwhile. Press Someone buys a tea and then Leave the tab and come back: the new stock arrives. With Infinity, the stock you first saw stays on screen however often you come back.

After a change, mark the copy stale

Reading is half of it. Pages also change server data: add to a cart, like a review, rename a list. A request that changes data is called a mutation. After one, some copies in the cache are wrong, and only your code knows which. Here the cart page saves a tea but tells the cache nothing. The cache.js tab is step 3, which adds three exports at the end.

Adding to the cart, with an old copy (a bug on purpose)Pop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { useQuery } from "./cache.js";import { addToCart, fetchCart } from "./api.js"; function CartBadge() {  const { data, isPending, isFetching } = useQuery({ queryKey: ["cart"], queryFn: fetchCart });  if (isPending) return <p>Cart: loading…</p>;  return <p>Cart: {data.length} items {isFetching && "(updating…)"}</p>;} export default function App() {  const [saving, setSaving] = useState(false);   async function addTea() {    setSaving(true);    await addToCart("tea");    setSaving(false);  }   return (    <>      <CartBadge />      <button disabled={saving} onClick={addTea}>        {saving ? "Adding…" : "Add Green tea"}      </button>    </>  );}
ResultLoading…

Line 6 reads the cart under the key ["cart"]. addTea on lines 14 to 18 saves a tea on the server (the api.js tab takes 0.7 seconds) and lets the button show Adding… meanwhile. Press Add Green tea: the console says Save: add tea, the server's cart now has one item, and the badge still says Cart: 0 items. The copy in the cache was fresh as far as the cache knew, and nothing told it otherwise.

The fix is to invalidate the copy: mark it stale and ask again. Step 3's invalidateQueries, on lines 63 to 70 of cache.js, does that:

Invalidating the cart after the savePop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { invalidateQueries, useQuery } from "./cache.js";import { addToCart, fetchCart } from "./api.js"; function CartBadge() {  const { data, isPending, isFetching } = useQuery({ queryKey: ["cart"], queryFn: fetchCart });  if (isPending) return <p>Cart: loading…</p>;  return <p>Cart: {data.length} items {isFetching && "(updating…)"}</p>;} export default function App() {  const [saving, setSaving] = useState(false);   async function addTea() {    setSaving(true);    await addToCart("tea");    invalidateQueries(["cart"]);    setSaving(false);  }   return (    <>      <CartBadge />      <button disabled={saving} onClick={addTea}>        {saving ? "Adding…" : "Add Green tea"}      </button>    </>  );}
ResultLoading…

Line 17 calls invalidateQueries(["cart"]) once the save is done. Press Add Green tea: the badge says (updating…), the console says Request 2: cart, and then Cart: 1 items. In cache.js, line 64 turns the key into the start of a string, ["cart", so line 66 matches every key that begins with it: ["cart"] and ["cart", "count"], but not ["product", "tea"]. Line 67 marks each match stale, and line 68 asks again with the queryFn that line 24 now keeps in every entry.

Top: addToCart saves a tea on the server, whose cart now holds Green tea. Below, the cache still holds the old, empty cart, crossed out. An arrow labelled invalidateQueries cart marks it stale and asks again, and the new copy holds Green tea.addToCart("tea")servercart: ["Green tea"]in the cachecart: []invalidateQueries(["cart"])stale: ask againcart: ["Green tea"]the new copy
Saving changes the server's cart, but the cache can't know. invalidateQueries(["cart"]) marks every copy whose key starts with "cart" as stale, and asks again for the ones on screen.

That's why keys are arrays that start with the general and end with the specific, like ["product", id]. One call can then cover one product, ["product", "tea"], or every product, ["product"]. The toy asks again for every match, even ones no component shows. TanStack Query only marks those stale, and asks for them when they next appear.

Optimistic updates in the cache

Invalidating waits for two trips to the server, the save and the new copy, before the reader sees their tea. When a change almost always works, you can show it at once instead: an optimistic update. You met the idea in Optimistic UI, with useOptimistic for one component's state. With a cache, you write the expected result into the cache itself, so every component that reads that key shows it.

Adding to the cart optimistically, with a rollbackPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { getQueryData, invalidateQueries, setQueryData, useQuery } from "./cache.js";import { addToCart, fetchCart } from "./api.js"; function Cart() {  const { data, isPending } = useQuery({ queryKey: ["cart"], queryFn: fetchCart });  if (isPending) return <p>Cart: loading…</p>;  return <p>Cart: {data.length ? data.join(", ") : "empty"}</p>;} export default function App() {  const [error, setError] = useState(null);   async function add(id, name) {    setError(null);    const before = getQueryData(["cart"]);    setQueryData(["cart"], (cart = []) => [...cart, name]);    try {      await addToCart(id);    } catch (reason) {      setQueryData(["cart"], () => before);      setError(reason.message);    }    invalidateQueries(["cart"]);  }   return (    <>      <Cart />      <button onClick={() => add("tea", "Green tea")}>Add Green tea</button>      <button onClick={() => add("cake", "Carrot cake")}>Add Carrot cake</button>      {error && <p>{error}</p>}    </>  );}
ResultLoading…

Line 16 keeps the copy as it was, with step 3's getQueryData. Line 17 writes the cart with the new item into the cache with setQueryData, so the cart shows it before the server has answered. Line 19 saves. If the server says no, line 21 puts the saved copy back, and line 22 shows why. Either way, line 24 invalidates the cart, so the page ends on what the server really has.

Press Add Green tea: Cart: Green tea at once. Press Add Carrot cake: it shows Green tea, Carrot cake for 0.7 seconds, then the server says Carrot cake is sold out., and the cart is back to Green tea. Here is the same update with TanStack Query's useMutation, as a fragment:

The same optimistic add with TanStack QueryJSX
const queryClient = useQueryClient();
const addMutation = useMutation({
  mutationFn: ({ id }) => addToCart(id),
  onMutate: async ({ name }) => {
    await queryClient.cancelQueries({ queryKey: ["cart"] });
    const before = queryClient.getQueryData(["cart"]);
    queryClient.setQueryData(["cart"], (cart = []) => [...cart, name]);
    return { before };
  },
  onError: (error, variables, saved) => {
    queryClient.setQueryData(["cart"], saved.before);
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["cart"] }),
});

Lines 4 to 9 run before the request: line 5 cancels any request for the cart that is still going, so an old answer can't land on top of the new item, which the toy doesn't do. Lines 6 and 7 save the copy and write the new one, and line 8 returns the saved copy. TanStack Query hands it to onError as the third value, and line 11 puts it back. Line 13 invalidates the cart whether the save worked or not.

The same ideas in TanStack Query and SWR

Here is the product page from the overview with the real TanStack Query, version 5.105.0. The result is live: the library loads from a CDN (a server for packages) when the example appears, and the product still comes from the pretend api.js. (Checked on npm in October 2026: TanStack Query 5.105.0 and SWR 2.5.1 were the newest releases of versions 5 and 2. Both work with React 18 and 19.)

The product page on TanStack QueryPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query@5.105.0?dev&deps=react@19.3.1";import { fetchProduct } from "./api.js"; const queryClient = new QueryClient(); function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending, isFetching } = useProduct("tea");  if (isPending) return <p>Loading…</p>;  return <p>{data.stock} left {isFetching && "(updating…)"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <QueryClientProvider client={queryClient}>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </QueryClientProvider>  );}
ResultLoading…

Line 5 makes a QueryClient, the object that holds the cache, and line 28 passes it to every component below with QueryClientProvider, a context provider. Lines 8 to 11 are the same useQuery call as in the toy: a key and a function. Lines 20 to 22 read isPending and isFetching, which mean what they meant in the toy. Run it with the console open: one request for two components. Hide and Show: the copy, (updating…), and a second request, because staleTime is 0 here too.

Line 2 is the one odd line. In your own project you install the package once, and import it by its name:

Install TanStack Query in a projectShell
npm install @tanstack/react-query
main.jsx: one QueryClient for the whole appJSX
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

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

Line 1 imports from "@tanstack/react-query", line 3 makes one client for the app, and lines 6 to 8 put the provider around everything, so any component can call useQuery. The live example's import instead names the version and adds ?dev&deps=react@19.3.1. That asks the CDN for the library's development build, built against the same React the result uses. Without it, the library would load a second copy of React, and its hooks would fail with Cannot read properties of null (reading 'useEffect'): React's hooks only work with the one React that is rendering.

And here is the same page with SWR, version 2.5.1:

The product page on SWRPop out in the code editor (opens in a new tab)JSX
import { useState } from "react";import useSWR from "swr@2.5.1?dev&deps=react@19.3.1";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useSWR(["product", id], () => fetchProduct(id));} function ProductTitle() {  const { data, isLoading } = useProduct("tea");  return <h2>{isLoading ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isLoading, isValidating } = useProduct("tea");  if (isLoading) return <p>Loading…</p>;  return <p>{data.stock} left {isValidating && "(updating…)"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
ResultLoading…

Line 6 passes SWR the key and the fetcher as two arguments. SWR needs no provider: it makes its cache for you. It names things a little differently: line 10's isLoading is TanStack Query's isPending, and line 15's isValidating is isFetching. To mark a key stale after a change, SWR uses mutate:

Asking again after a change, in SWRJSX
import { useSWRConfig } from "swr";

function AddTeaButton() {
  const { mutate } = useSWRConfig();
  async function addTea() {
    await addToCart("tea");
    mutate(["cart"]); // mark the cart stale and ask again
  }
  return <button onClick={addTea}>Add Green tea</button>;
}

Line 4 gets SWR's mutate from useSWRConfig, and line 7 calls it with the key once the save is done, which marks the cart stale and asks again: what invalidateQueries did in the toy. How to read the table: each row is one idea from this lesson, under its name in the toy, in TanStack Query, and in SWR.

One idea, three names
The ideaThe toy cacheTanStack QuerySWR
The name of a piece of dataqueryKey: ["product", id]queryKey: ["product", id]The first argument: ["product", id]
The function that asks the serverqueryFnqueryFnThe second argument, the fetcher
Nothing has arrived yetisPendingisPendingisLoading
Asking again, with a copy on screenisFetchingisFetchingisValidating
How long a copy counts as freshstaleTime, 0 by defaultstaleTime, 0 by defaultAlways stale; dedupingInterval (2 s) joins repeated requests
Mark copies stale and ask againinvalidateQueries(["cart"])queryClient.invalidateQueries({ queryKey: ["cart"] })mutate(["cart"])
Write into the cachesetQueryDataqueryClient.setQueryDatamutate(key, data), or the optimisticData option
Where the cache livesA Map in cache.jsA QueryClient, passed down by QueryClientProviderA cache SWR makes for you; SWRConfig can give it options

The toy left out most of what makes these libraries worth installing. Both real ones also:

  • Retry a failed request before showing the error, waiting longer each time (TanStack Query tries three more times).
  • Keep requests in order, help with lists that load page by page, and work with server rendering.
  • Work with Suspense: TanStack Query's useSuspenseQuery and SWR's suspense option let a Suspense boundary show the loading state.

TanStack Query also forgets copies nobody has used for five minutes (its gcTime), so its cache doesn't grow forever, and it has developer tools that show the cache.

And sometimes you need neither. A framework such as Next.js or React Router can load a page's data on the server, before the page renders, and refresh it after a form submits. If your framework does that, use its way first, and reach for a library for what's left, like data that changes while the page is open.

A shop cart on TanStack Query

Here is a small shop with the real library: a cart badge, two Add buttons, and the list of what's in the cart. Adding a tea works; adding a cake fails, because it's sold out.

A cart with useQuery and useMutationPop out in the code editor (opens in a new tab)JSX
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from "@tanstack/react-query@5.105.0?dev&deps=react@19.3.1";import { addToCart, fetchCart } from "./api.js"; const queryClient = new QueryClient(); function useCart() {  return useQuery({ queryKey: ["cart"], queryFn: fetchCart });} function CartBadge() {  const { data, isPending } = useCart();  return <p>Cart: {isPending ? "…" : data.length} items</p>;} function CartList() {  const { data, isFetching } = useCart();  if (!data) return null;  return <p>{data.length ? data.join(", ") : "Nothing yet"} {isFetching && "(updating…)"}</p>;} function AddButton({ id, name }) {  const client = useQueryClient();  const add = useMutation({    mutationFn: () => addToCart(id),    onSuccess: () => client.invalidateQueries({ queryKey: ["cart"] }),  });  return (    <p>      <button disabled={add.isPending} onClick={() => add.mutate()}>        {add.isPending ? "Adding…" : `Add ${name}`}      </button>      {add.isError && ` ${add.error.message}`}    </p>  );} export default function App() {  return (    <QueryClientProvider client={queryClient}>      <CartBadge />      <AddButton id="tea" name="Green tea" />      <AddButton id="cake" name="Carrot cake" />      <CartList />    </QueryClientProvider>  );}
ResultLoading…

useCart on lines 6 to 8 is a small custom hook around useQuery, so CartBadge (line 11) and CartList (line 16) ask for the cart the same way, with the same key. AddButton uses useMutation on lines 23 to 26: mutationFn is the save, and onSuccess invalidates the cart through the client from useQueryClient on line 22. Line 29 starts the save with add.mutate() and disables the button while add.isPending. Line 32 shows add.error.message when the save fails.

Press Add Green tea. The button says Adding… for the save and the new copy together: when onSuccess returns the promise from invalidateQueries, as it does here, the mutation waits for the cart to arrive. Then the badge and the list both change, though only one request ran. Press Add Carrot cake: Carrot cake is sold out. appears next to its button, and the cart doesn't change.

Client state or server state?
  • Whether the account menu is open
  • The text in the search box, as you type
  • Which tab of the product page you picked
  • A review you're still writing, before you press Send
  • How many teas are left in stock
  • Your cart, saved on the shop's server
  • Your list of past orders
  • The number of likes on a review
Try it yourself
0 of 8 correct

Sort each piece of data: does it belong in useState, or is it a copy of the server's data for a query?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "A data library replaces useState." No. It holds server state. The open menu, the tab, and the text in a box stay in useState or a reducer.
  • "Data from the cache is always up to date." No. It's a copy, often stale. The library shows it and asks again; with a long staleTime, it may not ask for a while.
  • "Two components with the same key send two requests." No. They share one entry and one request. Different keys, even for the same data, don't share.
  • "The library notices when I change data on the server." No. It can't see your save. Invalidate the keys it affects, or write the new data into the cache.
  • "isFetching means there's nothing to show." No. That's isPending (isLoading in SWR). isFetching is also true while a stale copy is on screen.
  • "The query key is just a label." No. It decides which components share data, and what invalidating reaches. Put every value the request uses in it.
  • "An optimistic update can skip the error case." No. When the server says no, put the saved copy back and show why, then invalidate, or the page shows something the server never saved.

Practice exercises

The exercises use the toy cache, in each one's cache.js tab, so you can read every line that runs. Open the console under each result to see the requests. Run again starts the pretend server and the cache from nothing.

Exercise 1 · Warm-upCount the requests

This page shows the price twice and the stock once, on step 1's cache, in StrictMode. How many requests does it send? Answer first, then press Edit & run and open the console.

import { useQuery } from "./cache.js";
import { fetchProduct } from "./api.js";

function Price() {
  const { data } = useQuery({ queryKey: ["product", "tea"], queryFn: () => fetchProduct("tea") });
  return <p>Price: {data ? `$${data.price}` : "…"}</p>;
}

function Stock() {
  const { data } = useQuery({ queryKey: ["product", "tea"], queryFn: () => fetchProduct("tea") });
  return <p>Stock: {data ? data.stock : "…"}</p>;
}

export default function App() {
  return (
    <>
      <Price />
      <Stock />
      <Price />
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upGive each product its own key

    Click Cake: the page still says Green tea. Fix the key. After your fix, what does the page show for Cake?

    import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function Product({ id }) {  const { data, isPending } = useQuery({    queryKey: ["product"],    queryFn: () => fetchProduct(id),  });  return <h2>{isPending ? "Loading…" : data.name}</h2>;} export default function App() {  const [id, setId] = useState("tea");  return (    <>      <button onClick={() => setId("tea")}>Tea</button>      <button onClick={() => setId("cake")}>Cake</button>      <Product id={id} />    </>  );}
    ResultLoading…

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeKeep the copy fresh for ten seconds

      Every Show asks the server again. Make a copy fresh for ten seconds. With your fix, how many requests does a Hide and Show within ten seconds add?

      import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending, isFetching } = useProduct("tea");  if (isPending) return <p>Loading…</p>;  return <p>{data.stock} left {isFetching && "(updating…)"}</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      <button onClick={() => window.dispatchEvent(new Event("online"))}>Back online</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
      ResultLoading…

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeKeep the old copy on screen

        Hide and Show: the stock says Loading… again, though the cache has a copy. Show the copy while it refetches. After your fix, what does the stock line show right after Show?

        import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function useProduct(id) {  return useQuery({    queryKey: ["product", id],    queryFn: () => fetchProduct(id),  });} function ProductTitle() {  const { data, isPending } = useProduct("tea");  return <h2>{isPending ? "Loading…" : data.name}</h2>;} function StockBadge() {  const { data, isPending, isFetching } = useProduct("tea");  if (isPending || isFetching) return <p>Loading…</p>;  return <p>{data.stock} left</p>;} export default function App() {  const [open, setOpen] = useState(true);  return (    <>      <button onClick={() => setOpen(!open)}>{open ? "Hide" : "Show"} the product</button>      {open && (        <>          <ProductTitle />          <StockBadge />        </>      )}    </>  );}
        ResultLoading…

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeUpdate the cart after adding

          Press Add Green tea: the server saves it, and the badge still says 0. Make the badge catch up. After your fix and one Add, what does the badge say once the cart is back?

          import { useState } from "react";import { useQuery } from "./cache.js";import { addToCart, fetchCart } from "./api.js"; function CartBadge() {  const { data, isPending, isFetching } = useQuery({ queryKey: ["cart"], queryFn: fetchCart });  if (isPending) return <p>Cart: loading…</p>;  return <p>Cart: {data.length} items {isFetching && "(updating…)"}</p>;} export default function App() {  const [saving, setSaving] = useState(false);   async function addTea() {    setSaving(true);    await addToCart("tea");    setSaving(false);  }   return (    <>      <CartBadge />      <button disabled={saving} onClick={addTea}>        {saving ? "Adding…" : "Add Green tea"}      </button>    </>  );}
          ResultLoading…

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeLike at once, and undo on failure

            The like count only changes after the save and a new copy. Make it count the like at once, and undo it when the server says no. Click three times, waiting for each. After the third click fails, what does the button say?

            import { useState } from "react";import { invalidateQueries, useQuery } from "./cache.js";import { fetchLikes, saveLike } from "./api.js"; export default function LikeButton() {  const { data: likes, isPending } = useQuery({ queryKey: ["likes"], queryFn: fetchLikes });  const [error, setError] = useState(null);   async function like() {    setError(null);    try {      await saveLike();    } catch (reason) {      setError(reason.message);    }    invalidateQueries(["likes"]);  }   if (isPending) return <p>Loading…</p>;  return (    <>      <button onClick={like}>Like ({likes})</button>      {error && <p>{error}</p>}    </>  );}
            ResultLoading…

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what the cache holds under each key, and whether that copy is fresh, stale, or missing.

              The Data fetching libraries quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Which of these is server state?

                Choose an answer to see the explanation.

              2. Question 2 of 7With the lesson's step 1 cache, how many times does fetchProduct run?

                Read the code, then predictJSX
                function Name() {
                  const { data } = useQuery({ queryKey: ["product", "cake"], queryFn: () => fetchProduct("cake") });
                  return <p>{data?.name}</p>;
                }
                
                function Price() {
                  const { data } = useQuery({ queryKey: ["product", "cake"], queryFn: () => fetchProduct("cake") });
                  return <p>{data?.price}</p>;
                }
                
                // <Name /> and <Price /> appear together, on step 1's cache.

                Choose an answer to see the explanation.

              3. Question 3 of 7A query with staleTime: 0 already has a copy in the cache. A component that reads it appears. What does the reader see?

                Choose an answer to see the explanation.

              4. Question 4 of 7You add a tea to the cart, and the server saves it. Why call invalidateQueries({ queryKey: ["cart"] }) afterwards?

                Choose an answer to see the explanation.

              5. Question 5 of 7In the lesson's cache, which keys does this call mark stale?

                Read the code, then predictJavaScript
                invalidateQueries(["cart"]);
                
                // The cache holds these three keys:
                // ["cart"]
                // ["cart", "count"]
                // ["product", "tea"]

                Choose an answer to see the explanation.

              6. Question 6 of 7A component shows a product, and isFetching is true while isPending is false. What is happening?

                Choose an answer to see the explanation.

              7. Question 7 of 7What does an optimistic update do with the cache when the server says no?

                Choose an answer to see the explanation.

              Key takeaways

              • Server state is a copy of data a server holds. It needs loading, sharing, and refreshing; client state stays in useState.
              • A data library keeps one cache. The query key names each entry, so components with the same key share one request and one answer.
              • A stale copy is shown at once while the library asks again: stale-while-revalidate. staleTime says how long a copy is fresh.
              • Libraries also ask again when you come back to the tab and when the connection returns.
              • After a change, invalidate the keys it affects. For an instant result, write into the cache first, and put the old copy back if the server says no.
              • TanStack Query (useQuery, useMutation, invalidateQueries) and SWR (useSWR, mutate) do all of this, plus retries and more. A framework may load data for you first.

              Remember the one-liner.
              Name each copy with a key, share it, show it while you check it, and invalidate it when you change it.

              Coming next: Transitions. They let React keep typing and clicking fast while a slow update renders in the background.

              CompleteFrontend Clear concepts. Working examples.