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.
- 01Tell 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.
- 02Explain 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.
- 03Change 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.
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 /> </> )} </> );}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.
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.
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
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:
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.
| Question | Client state | Server 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. |
| Examples | An 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.
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 /> </> )} </> );}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.
saveon lines 13 to 16 replaces a key's entry with a new object and tells every listener. A new object each time is whatuseSyncExternalStoreneeds to see a change.fetchQueryon 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 withJSON.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
entryandqueryFnwhile the effect only depends onkey.
["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:
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 /> </> )} </> );}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:truewhenever a request is going, even with a copy on screen. Line 49'sisPendingstaystrueonly while there's no copy at all. InApp.jsx, line 19 showsLoading…only forisPending, and line 20 adds(updating…)forisFetching.
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:
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 /> </> )} </> );}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.
nothing shownNo product on the page.
staleTime: 0The cache is empty. Show a product.
No requests yet.
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.
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.
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> </> );}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:
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> </> );}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.
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.
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>} </> );}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:
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.)
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> );}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:
npm install @tanstack/react-queryimport { 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:
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 /> </> )} </> );}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:
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.
| The idea | The toy cache | TanStack Query | SWR |
|---|---|---|---|
| The name of a piece of data | queryKey: ["product", id] | queryKey: ["product", id] | The first argument: ["product", id] |
| The function that asks the server | queryFn | queryFn | The second argument, the fetcher |
| Nothing has arrived yet | isPending | isPending | isLoading |
| Asking again, with a copy on screen | isFetching | isFetching | isValidating |
| How long a copy counts as fresh | staleTime, 0 by default | staleTime, 0 by default | Always stale; dedupingInterval (2 s) joins repeated requests |
| Mark copies stale and ask again | invalidateQueries(["cart"]) | queryClient.invalidateQueries({ queryKey: ["cart"] }) | mutate(["cart"]) |
| Write into the cache | setQueryData | queryClient.setQueryData | mutate(key, data), or the optimisticData option |
| Where the cache lives | A Map in cache.js | A QueryClient, passed down by QueryClientProvider | A 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
useSuspenseQueryand SWR'ssuspenseoption 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.
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> );}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.
- 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
Sort each piece of data: does it belong in useState, or is it a copy of the server's data for a query?
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
useStateor 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.
- "
isFetchingmeans there's nothing to show." No. That'sisPending(isLoadingin SWR).isFetchingis alsotruewhile 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.
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 />
</>
);
}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 /> </> );}One. The three components, and StrictMode's second run of each effect, all ask for ["product", "tea"]. The first call to fetchQuery stores a promise in that entry, and every later call returns early on line 19. The console says Request 1: product tea once.
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} /> </> );}import { useState } from "react";import { useQuery } from "./cache.js";import { fetchProduct } from "./api.js"; function Product({ id }) { const { data, isPending } = useQuery({ queryKey: ["product", id], 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} /> </> );}With ["product"], tea and cake share one entry. Tea's answer is already there, so Cake shows Green tea and never asks. With ["product", id], cake has its own entry: Loading…, a request, then Carrot cake.
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 /> </> )} </> );}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 /> </> )} </> );}With staleTime: 10000, a copy is fresh for ten seconds after it arrives. Hide and Show inside that time: refetchIfStale sees an age under 10000 and doesn't ask. No request, and no (updating…).
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 /> </> )} </> );}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> {open && ( <> <ProductTitle /> <StockBadge /> </> )} </> );}isFetching is true for every request, including the background one after Show, so the badge hid a good copy. isPending is true only when there's no copy. Now Show gives 9 left (updating…) at once.
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> </> );}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> </> );}After the save, invalidateQueries(["cart"]) marks the cart stale and asks again with its queryFn. The badge shows (updating…), then Cart: 1 items.
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>} </> );}import { useState } from "react";import { getQueryData, invalidateQueries, setQueryData, 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); const before = getQueryData(["likes"]); setQueryData(["likes"], (count) => count + 1); try { await saveLike(); } catch (reason) { setQueryData(["likes"], () => before); setError(reason.message); } invalidateQueries(["likes"]); } if (isPending) return <p>Loading…</p>; return ( <> <button onClick={like}>Like ({likes})</button> {error && <p>{error}</p>} </> );}Line 12 adds the like to the cache before the save, so the button counts it at once. The third save fails: line 16 puts back 4, the count before that click, and line 17 shows the message. Line 19 then checks with the server, which also says 4.
Check your understanding
For each question, ask what the cache holds under each key, and whether that copy is fresh, stale, or missing.
Question 1 of 7Which of these is server state?
Choose an answer to see the explanation.
Question 2 of 7With the lesson's step 1 cache, how many times does
fetchProductrun?Read the code, then predictJSXfunction 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.
Question 3 of 7A query with
staleTime: 0already has a copy in the cache. A component that reads it appears. What does the reader see?Choose an answer to see the explanation.
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.
Question 5 of 7In the lesson's cache, which keys does this call mark stale?
Read the code, then predictJavaScriptinvalidateQueries(["cart"]); // The cache holds these three keys: // ["cart"] // ["cart", "count"] // ["product", "tea"]Choose an answer to see the explanation.
Question 6 of 7A component shows a product, and
isFetchingistruewhileisPendingisfalse. What is happening?Choose an answer to see the explanation.
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.
staleTimesays 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.