Fetching data
Fetch data in a React effect without race conditions: drop or cancel old requests, show loading and error states, and avoid request waterfalls.
- 01Fetch in an effect without racesLoad data when a component appears or a prop changes, and use the cleanup to ignore or cancel answers that arrive too late.
- 02Show every state of a requestKeep one status for loading, done, and error, check failed responses, and let the reader retry.
- 03Spot waterfalls and their costSee when one request waits for another, start independent requests together, and explain why apps use a framework or a data library.
A product page that waits for the server
Most pages show data that lives on a server: a product, a list of orders, a profile. The page can't have it when it first appears. It has to ask, wait, and show the answer when it comes. In React, the simplest place to ask is an effect. Here is a product page that does it. Its server is pretend: the api.js tab answers after a delay, like a real one would, and nothing goes over the network. The result is live; press Run again to watch it load.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { console.log("Fetch tea"); fetchProduct("tea").then((data) => { console.log("Show tea"); setProduct(data); }); }, []); if (product === null) return <p>Loading…</p>; return <h2>{product.name}: ${product.price}</h2>;}Line 5 keeps the product in state, starting as null: nothing has arrived yet. On the first render, line 15 sees null and shows Loading…. After React has put that on the screen, it runs the effect on lines 7 to 13. Line 9 calls fetchProduct("tea"), which returns a promise: a value that arrives later. In the api.js tab, line 4 says tea takes 1500 ms. When the answer comes, line 11 stores it with setProduct. That causes a second render, and this time line 16 shows Green tea: $3. The empty array on line 13 says the effect has nothing to react to, so it runs when the page first appears.
Open the console under the result. It says Fetch tea and Show tea with a small 2 beside each: the effect ran twice. That's StrictMode, which every example here runs in, as a new project does. In development only, it runs each new effect, cleans it up, and runs it again, to check that the cleanup works. This one has no cleanup, so both requests answer. You fix that in a moment.
Loading…: the effect asks for the product after React has shown the page. When the answer arrives, setProduct stores it, and React renders again with the data.At a busy café you order, get a buzzer, and sit down. Your table is empty until the buzzer goes off. If you change your order to a cake, you also need to throw the tea buzzer away. Otherwise it may go off later, and you'll walk off with a tea you no longer want.
- In real life: You order a tea and get a buzzer
- In React: The effect calls
fetchProduct("tea")and gets a promise - In real life: You sit down with an empty tray
- In React: The page renders
Loading…whileproductisnull - In real life: The buzzer goes off and you collect the tea
- In React: The promise resolves and
setProductstores the answer - In real life: You change your order and drop the old buzzer in the bin
- In React: The cleanup ignores or cancels the old request
To load data in a component, ask for it in an effect and store the answer in state. The first render shows a loading state. When the answer arrives, setting state renders the page again with the data. Each run of the effect must clean up, so that an answer for an old request never replaces the one you asked for last.
Why the request goes in an effect
A fetch is a side effect: it reaches outside the component, to a server. React asks that rendering stays a pure calculation of the screen (Keeping components pure explains why), because React may call your component many times. So this is wrong, on purpose:
function ProductPage() {
const [product, setProduct] = useState(null);
// Wrong: this line runs on every render.
fetchProduct("tea").then((data) => setProduct(data));
return <h2>{product?.name}</h2>;
}Line 4 runs on every render. The first render asks for tea. When the answer comes, setProduct causes a second render, which asks for tea again, whose answer causes a third render, and so on: a request every 1.5 seconds, for as long as the page is open. It isn't live here for that reason. An effect runs after React has shown the page, and only again when one of its dependencies changes.
You may want to write the effect itself as an async function. That doesn't work either:
useEffect(async () => {
const data = await fetchProduct("tea");
setProduct(data);
}, []);An async function always returns a promise. React expects an effect to return a cleanup function or nothing, so in development it prints useEffect must not return anything besides a function, which is used for clean-up. Worse, React keeps the promise as if it were the cleanup, and calls it when the cleanup is due. Calling a promise throws an error, which in development reads TypeError: destroy is not a function (destroy is React's own name for a cleanup). StrictMode cleans up every new effect at once, so in development the page breaks as soon as it appears. Instead, write an async function inside the effect and call it:
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { async function load() { console.log("Fetch tea"); const data = await fetchProduct("tea"); setProduct(data); } load(); }, []); if (product === null) return <p>Loading…</p>; return <h2>{product.name}: ${product.price}</h2>;}Lines 8 to 12 define load, which can use await on line 10. Line 13 calls it. The effect function itself stays a normal function that returns nothing, so React is happy, and you can still add a cleanup with return. Both styles, .then and await, do the same thing; the rest of this lesson uses whichever reads better.
Switch quickly and the wrong product wins
Real pages change what they ask for: a product picker, a tab, a search box. Here the page can switch between two products. In api.js, cake answers after 300 ms and tea after 1500 ms.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); useEffect(() => { console.log(`Fetch ${productId}`); fetchProduct(productId).then((data) => { console.log(`Show ${productId}`); setProduct(data); }); }, [productId]); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <p>Picked: {productId}</p> {product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>} </> );}Line 5 keeps which product is picked, and line 6 the product that arrived. The effect on lines 8 to 14 lists productId in its dependencies, so it runs again whenever you pick another product, and asks for that one. Click Tea, then Cake straight away. The page shows Picked: cake and Carrot cake, and then, about a second later, it switches to Green tea: $3, though cake is still picked. The console says why: Fetch tea, Fetch cake, Show cake, Show tea.
That's a race condition: two requests race, and whichever answers last wins, not the one you asked for last. Nothing on line 12 knows that tea is no longer wanted. You met this bug with a search box in You might not need an effect; any effect that fetches for a value that can change has it. The playground runs this page with three ways to write its effect. Each bar is one request, from the moment the effect starts it to its end.
no cleanupPicked: nothing yet
Showing: nothing yet
0 requestsNo requests yet.
Click Tea, then Cake straight away, before tea's 1.5 seconds are up. Then watch which answer arrives last.
With No cleanup, click Tea then Cake: the tea bar ends last and turns red, shown, but it's not the one you picked. Try clicking Cake then Tea instead: nothing goes wrong, because the slow request is also the newest. That's what makes race conditions hard to find: they depend on timing. Then choose ignore flag and click Tea, Cake again.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; console.log(`Fetch ${productId}`); fetchProduct(productId).then((data) => { if (ignore) { console.log(`Ignore ${productId}`); return; } console.log(`Show ${productId}`); setProduct(data); }); return () => { ignore = true; }; }, [productId]); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <p>Picked: {productId}</p> {product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>} </> );}Line 9 makes a flag, ignore, for this run of the effect. Lines 19 to 21 return the cleanup: React calls it before it runs the effect again for a new productId, and when the page leaves the screen. It sets this run's flag to true. When an answer arrives, lines 12 to 15 check the flag of the run that asked, and drop the answer if that run was cleaned up. Click Tea, then Cake: the console ends with Show cake and Ignore tea, and the page stays on cake.
ignore. Picking cake runs the tea run's cleanup, which sets that run's ignore to true. When tea's answer arrives later, its callback reads its own ignore and drops the answer.Why does each answer see the right flag? Every run of the effect is a new call of the effect function, with its own let ignore. The then callback on line 11 remembers the ignore of the call that made it: a closure. Look at the console from the first run too: Fetch cake twice, then Ignore cake and Show cake. StrictMode's check cleaned up the first run, and the cleanup did its job.
Cancel the old request with AbortController
The ignore flag throws the old answer away, but the old request still runs to the end. When it's a big download, or the server does real work for it, you can stop it instead. The browser's tool for that is an AbortController (the Cancellation lesson covers it in full). It has two parts:
controller.signal, which you pass to the request.fetchaccepts it asfetch(url, { signal }), and the pretendfetchProductinapi.jsdoes too (lines 12 to 15).controller.abort(), which cancels it. The request stops, and its promise rejects with an error namedAbortError.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); useEffect(() => { const controller = new AbortController(); console.log(`Fetch ${productId}`); fetchProduct(productId, { signal: controller.signal }) .then((data) => { console.log(`Show ${productId}`); setProduct(data); }) .catch((error) => { if (error.name === "AbortError") console.log(`Cancelled ${productId}`); }); return () => { controller.abort(); }; }, [productId]); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <p>Picked: {productId}</p> {product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>} </> );}Line 9 makes a controller for this run, and line 11 passes its signal to fetchProduct. The cleanup on lines 19 to 21 aborts it. A cancelled request never answers, so the then on line 12 can't run for it; instead, its promise rejects, and the catch on lines 16 to 18 runs. An AbortError isn't a real failure, so line 17 only logs it. Click Tea, then Cake: the console says Fetch tea, Fetch cake, Cancelled tea, Show cake. In the playground, choose AbortController: the tea bar now stops the moment you click Cake.
How to read the table: each row is one question, answered for each way of cleaning up.
| Question | An ignore flag | An AbortController |
|---|---|---|
| What does the cleanup do? | It sets the old run's ignore to true. | It calls controller.abort() on the old run's controller. |
| What happens to the old request? | It keeps going and answers. The answer is thrown away. | It stops. The promise rejects with an AbortError, and no answer comes. |
| What must the request accept? | Nothing. It works with any promise. | A signal, as fetch does, and your own functions can. |
| What must the code handle? | Only the if (ignore) check. | An AbortError in catch, which isn't a real failure. |
Use an AbortController when the request accepts a signal, as fetch does. Use an ignore flag for anything else, like a library function that can't be cancelled. Either way, the rule is the same: an effect that fetches needs a cleanup.
Loading and error states
So far, a page with no product yet showed Loading…, and a failure would show nothing at all. A real page has three states: still loading, done, or failed. Keeping them in one piece of state, with a status, means the page can never be loading and failed at once (Choosing the state structure explains why one object beats several flags). This server fails sometimes: in its api.js, line 13 makes the first request for the scone fail, like a busy server.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; const menu = ["cake", "tea", "scone"]; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [attempt, setAttempt] = useState(1); const [result, setResult] = useState({ status: "loading" }); useEffect(() => { let ignore = false; fetchProduct(productId) .then((product) => { if (!ignore) setResult({ status: "done", product }); }) .catch((error) => { if (!ignore) setResult({ status: "error", message: error.message }); }); return () => { ignore = true; }; }, [productId, attempt]); function pick(id) { setProductId(id); setResult({ status: "loading" }); } function retry() { setAttempt(attempt + 1); setResult({ status: "loading" }); } return ( <> {menu.map((id) => ( <button key={id} disabled={id === productId} onClick={() => pick(id)}>{id}</button> ))} {result.status === "loading" && <p>Loading…</p>} {result.status === "error" && <p>{result.message} <button onClick={retry}>Retry</button></p>} {result.status === "done" && <h2>{result.product.name}: ${result.product.price}</h2>} </> );}Line 9 starts the result as { status: "loading" }. The effect on lines 11 to 23 stores either { status: "done", product } (line 15) or { status: "error", message } (line 18), and keeps the ignore flag from before. Lines 40 to 42 show one line for each status.
Two event handlers put the page back to loading. pick on lines 25 to 28 changes the product and sets the status in the same click, so the old product never shows under the new name. retry on lines 30 to 33 adds one to attempt. attempt is in the effect's dependencies on line 23, so a new number runs the effect again, which asks again. Click scone: after 0.7 seconds, The server is busy. Try again. appears with a Retry button. Press it, and the second request works: Cheese scone: $2.
status says which of the three the page shows. Picking a product or pressing Retry puts it back to "loading"; the answer, or the failure, moves it on.With a real server, there's one more thing to check. fetch only rejects when no answer comes back at all, like when you're offline. An answer that says "404 Not Found" or "500 Server Error" still resolves, with response.ok set to false (fetch covers it). So a real fetchProduct checks it and throws, which sends the page to its error state:
export async function fetchProduct(id, { signal } = {}) {
const response = await fetch(`/api/products/${id}`, { signal });
if (!response.ok) {
throw new Error(`The server answered ${response.status}.`);
}
return response.json();
}Line 2 passes the signal on to fetch, so it can be cancelled. Line 3 checks response.ok, and line 4 turns a bad answer into an error. Line 6 reads the answer as JSON. It isn't live because these examples never call a real server.
Waterfalls: requests that wait in line
Pages are made of components, and each one can fetch what it needs. That's tidy, but it can make requests wait for each other. Here a product page shows its reviews with a Reviews component (in the Reviews.jsx tab), which fetches them in its own effect. The api.js tab notes the time of every request and answer in the console.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js";import Reviews from "./Reviews.jsx"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; fetchProduct("tea").then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, []); if (product === null) return <p>Loading…</p>; return ( <> <h2>{product.name}: ${product.price}</h2> <Reviews productId="tea" /> </> );}Line 18 returns Loading… until the product arrives, and only after that, line 22 renders Reviews. A component's effect runs after it renders, so the reviews request can't start until the product has arrived. The console shows it: 0 ms: ask for product tea, 800 ms: product tea arrived, then 800 ms: ask for reviews tea, and 1500 ms: reviews tea arrived. (Each line has a 2 beside it: StrictMode again. The times are a little later in your browser than here, because the page takes a moment to start.)
This is a request waterfall: each request waits for the one before, like water falling from one step to the next. The reviews don't need anything from the product, so the wait is pure loss. The fix is to render Reviews at once:
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js";import Reviews from "./Reviews.jsx"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; fetchProduct("tea").then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, []); return ( <> {product === null ? <p>Loading…</p> : <h2>{product.name}: ${product.price}</h2>} <Reviews productId="tea" /> </> );}Line 20 now shows Loading… or the product in place, and line 21 renders Reviews right away. Both components render in the first commit, both effects run, and both requests start at 0 ms. The reviews arrive at 700 ms and the product at 800 ms: the page is complete at 800 ms instead of 1500.
Reviews renders only after the product arrives, so its request waits in line, like water falling from one step to the next. Bottom: both components render at once, both effects ask at once, and the page is complete when the slower answer arrives.not loadedPress a Load button.
none yetNo requests yet.
Each button loads the product page from the start, with the reviews waiting for the product or rendering at once.
Waterfalls also hide inside one effect, as two awaits in a row (exercise 5 has one), where Promise.all starts both at once (Sequential vs parallel explains it). Some waterfalls are real, though: if the reviews needed a sellerId that only the product's answer has, the second request must wait. In the browser, the only way round that is to ask the server for both in one request, or to let the server do the waiting, close to the data, which is what frameworks do.
What an effect can't do for you
Everything so far works, and it's worth knowing how it works. But look at how much code the product page needed for one request: a status, a flag or a controller, a cleanup, a retry counter. React's own documentation recommends a framework or a data library for real apps, because fetching in effects has costs that the code above doesn't fix:
- No cache. In the loading and error example, pick tea, wait, pick cake, then tea again. Tea shows
Loading…for 1.5 seconds again: every visit asks the server again, and nothing remembers the last answer. - No sharing. Two components that need the same product each send their own request.
- Nothing on the server. Effects only run in the browser. A page built on the server (Server rendering and hydration covers it) sends HTML that only says
Loading…, and the browser starts asking after its JavaScript has loaded. - Waterfalls. Each component asks only once it renders, as the last section showed.
- Repeated code. Every component that fetches needs the same cleanup and status. You could move them into a custom hook of your own, as in Reusing logic with custom hooks, but caching and sharing are still yours to build.
A framework like Next.js or React Router loads a page's data before rendering it, often on the server, and can start several requests at once. A data library like TanStack Query or SWR keeps a cache of answers, shares requests between components, and handles old answers, loading, and errors. Here is the product page with TanStack Query, as a sketch: the examples here don't install it, so it isn't live.
import { useQuery } from "@tanstack/react-query";
import { fetchProduct } from "./api.js";
function ProductPage({ productId }) {
const { data, error, isPending } = useQuery({
queryKey: ["product", productId],
queryFn: () => fetchProduct(productId),
});
if (isPending) return <p>Loading…</p>;
if (error) return <p>{error.message}</p>;
return <h2>{data.name}: ${data.price}</h2>;
}Lines 5 to 8 ask the library for the product. queryKey on line 6 names the answer in the cache, so a second visit to the same product, or a second component asking for it, gets the saved answer. queryFn on line 7 is the same fetchProduct as before. Lines 9 to 11 read the status the library keeps. There's no effect, no flag, and no cleanup in your code. The Data fetching libraries lesson covers how they work.
How to read the table: each row is a situation, with what happens with an effect and with a framework or library.
| Situation | Fetching in an effect | With a framework or library |
|---|---|---|
| You go back to a product you've seen | It loads again from the server, with Loading… again. | It shows the saved copy at once, and checks for a newer one. |
| Two components need the same product | Each effect sends its own request. | They share one request and one saved answer. |
| The page is built on the server | Effects don't run there, so the first HTML only says Loading…. | A framework loads the data on the server and sends the finished page. |
| A child needs data too | It asks only once it renders, after its parent's data. | A framework can start both requests together, before rendering. |
| Old answers, loading, and errors | You write the cleanup and the status in every component. | The library handles them; you read data, error, and isPending. |
One more thing an effect can't do: tell React that part of the page is waiting. Each component above draws its own Loading…. Suspense, the next lesson, lets you describe loading states for a whole part of the page in one place, and frameworks plug their data into it.
A product page that handles all of it
Here is everything together: a product picker that cancels old requests, a status for loading, done, and error with a retry, and reviews that load at the same time as the product. The Reviews.jsx tab cancels its own request the same way.
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js";import Reviews from "./Reviews.jsx"; const menu = ["cake", "tea", "scone"]; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [attempt, setAttempt] = useState(1); const [result, setResult] = useState({ status: "loading" }); useEffect(() => { const controller = new AbortController(); fetchProduct(productId, { signal: controller.signal }) .then((product) => setResult({ status: "done", product })) .catch((error) => { if (error.name === "AbortError") return; setResult({ status: "error", message: error.message }); }); return () => { controller.abort(); }; }, [productId, attempt]); function pick(id) { setProductId(id); setResult({ status: "loading" }); } function retry() { setAttempt(attempt + 1); setResult({ status: "loading" }); } return ( <> {menu.map((id) => ( <button key={id} disabled={id === productId} onClick={() => pick(id)}>{id}</button> ))} {result.status === "loading" && <p>Loading…</p>} {result.status === "error" && <p>{result.message} <button onClick={retry}>Retry</button></p>} {result.status === "done" && <h2>{result.product.name}: ${result.product.price}</h2>} <Reviews key={productId} productId={productId} /> </> );}Lines 12 to 23 are the effect: a controller per run (line 13), its signal passed on (line 14), and abort() in the cleanup (line 21). Line 17 skips the AbortError from a cancelled request, so only real failures reach the error state on line 18. Line 43 renders Reviews next to the product, not after it, so both requests start together. Its key gives each product a fresh Reviews, with its own null to start from, as Preserving and resetting state shows. Click tea, then scone, then cake, quickly: only cake and its review arrive. Click scone and wait for the error, then press Retry.
- An effect calls
fetchProduct(productId).then(setProduct)and returns no cleanup - A search box's effect calls
setResultswith every answer, and has no cleanup - The page returns
<p>Loading…</p>until its product arrives, and only then renders<Reviews /> - One effect runs
await fetchOrder(), thenawait fetchDelivery() - The effect's cleanup calls
controller.abort() - The effect uses the answer only
if (!ignore), and its cleanup setsignore = true <Product />and<Reviews />render side by side, each fetching in its own effect- One effect runs
await Promise.all([fetchOrder(), fetchDelivery()])
Sort each piece of fetching code: can an old answer win, do requests wait in line, or is it fine?
Common mix-ups
- "A quick fetch can go straight in the component body." No. The body runs on every render, and the answer's
setStatecauses another render, which fetches again. Fetch in an effect or an event handler. - "
useEffect(async () => …)is a shorter way to write it." No. It returns a promise instead of a cleanup. React warns, and then breaks the page when it tries to call that promise as the cleanup. Put an async function inside the effect and call it. - "Race conditions only happen on slow networks." No. They happen whenever answers can arrive in a different order than you asked, which a server can do on any network. In the playground, the server is local and it still happens.
- "The cleanup only matters when the component leaves the page." No. React also calls it before every re-run of the effect, which is exactly when the old request needs to be dropped.
- "
fetchrejects when the server answers 404 or 500." No. It resolves withresponse.okset tofalse. Check it, and throw. - "Seeing every request twice means my app sends two requests in production." No. StrictMode runs new effects twice in development only, to check your cleanup. With a cleanup, the first answer is dropped or cancelled.
- "Suspense will show its fallback while my effect loads." No. Suspense doesn't know about requests in effects. The next lesson shows what it does see.
Practice exercises
Each exercise has a live result: click in it, change the code with Edit & run, and open the console to see the requests. Run again starts the pretend server from scratch.
The page starts on cake. You click Tea, then Cake half a second later, and wait two seconds. Which product name does the page end on? Answer first, then press Edit & run and try it.
import { useEffect, useState } from "react";
import { fetchProduct } from "./api.js";
export default function ProductPage() {
const [productId, setProductId] = useState("cake");
const [product, setProduct] = useState(null);
useEffect(() => {
console.log(`Fetch ${productId}`);
fetchProduct(productId).then((data) => {
console.log(`Show ${productId}`);
setProduct(data);
});
}, [productId]);
return (
<>
<button onClick={() => setProductId("tea")}>Tea</button>
<button onClick={() => setProductId("cake")}>Cake</button>
<p>Picked: {productId}</p>
{product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>}
</>
);
}import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); useEffect(() => { console.log(`Fetch ${productId}`); fetchProduct(productId).then((data) => { console.log(`Show ${productId}`); setProduct(data); }); }, [productId]); return ( <> <button onClick={() => setProductId("tea")}>Tea</button> <button onClick={() => setProductId("cake")}>Cake</button> <p>Picked: {productId}</p> {product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>} </> );}Cake's answer arrives about 300 ms after you click Cake and shows Carrot cake. Tea's answer arrives at 1500 ms, last, and its setProduct replaces it: the page ends on Green tea: $3 while it says Picked: cake.
The page is blank until the product arrives. Make it say Loading… until then. What does it show for the first 1.5 seconds?
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; fetchProduct("tea").then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, []); return product && <h2>{product.name}: ${product.price}</h2>;}import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; export default function ProductPage() { const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; fetchProduct("tea").then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, []); if (product === null) return <p>Loading…</p>; return <h2>{product.name}: ${product.price}</h2>;}While product is null, line 17 returns the loading message. When the answer arrives, setProduct renders again and line 18 shows the product. Without it, product && … gives null, and the page is blank for 1.5 seconds.
Click London, then Paris straight away: the page says Deliver to: paris but shows London's estimate. Add a cleanup so the old answer is ignored. After your fix, what does the last line say?
import { useEffect, useState } from "react";import { fetchDelivery } from "./api.js"; export default function DeliveryEstimate() { const [city, setCity] = useState("paris"); const [estimate, setEstimate] = useState(""); useEffect(() => { fetchDelivery(city).then((text) => { setEstimate(text); }); }, [city]); return ( <> <button onClick={() => setCity("london")}>London</button> <button onClick={() => setCity("paris")}>Paris</button> <p>Deliver to: {city}</p> <p>{estimate}</p> </> );}import { useEffect, useState } from "react";import { fetchDelivery } from "./api.js"; export default function DeliveryEstimate() { const [city, setCity] = useState("paris"); const [estimate, setEstimate] = useState(""); useEffect(() => { let ignore = false; fetchDelivery(city).then((text) => { if (!ignore) setEstimate(text); }); return () => { ignore = true; }; }, [city]); return ( <> <button onClick={() => setCity("london")}>London</button> <button onClick={() => setCity("paris")}>Paris</button> <p>Deliver to: {city}</p> <p>{estimate}</p> </> );}Clicking Paris runs the London run's cleanup, which sets that run's ignore to true. When London's slow answer arrives, it's dropped, and the page keeps Paris: 4 days. Before the fix, London arrived last and replaced it.
Click scone. The first scone request fails, and nothing handles it, so the result stops with an error. Catch it and show its message on the page. What does the page show after you click scone?
import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; const menu = ["cake", "scone"]; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); useEffect(() => { let ignore = false; fetchProduct(productId).then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, [productId]); function pick(id) { setProductId(id); setProduct(null); } return ( <> {menu.map((id) => ( <button key={id} disabled={id === productId} onClick={() => pick(id)}>{id}</button> ))} {product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>} </> );}import { useEffect, useState } from "react";import { fetchProduct } from "./api.js"; const menu = ["cake", "scone"]; export default function ProductPage() { const [productId, setProductId] = useState("cake"); const [product, setProduct] = useState(null); const [error, setError] = useState(null); useEffect(() => { let ignore = false; fetchProduct(productId) .then((data) => { if (!ignore) setProduct(data); }) .catch((reason) => { if (!ignore) setError(reason.message); }); return () => { ignore = true; }; }, [productId]); function pick(id) { setProductId(id); setProduct(null); setError(null); } return ( <> {menu.map((id) => ( <button key={id} disabled={id === productId} onClick={() => pick(id)}>{id}</button> ))} {error && <p>{error}</p>} {!error && (product ? <h2>{product.name}: ${product.price}</h2> : <p>Loading…</p>)} </> );}Line 9 adds an error state. The .catch on lines 17 to 19 stores the message, unless the run was cleaned up. Line 36 shows it, and pick clears it so the next product can load. Without a catch, the rejected promise was never handled, so the page stopped with an error.
The order page waits for the order, then asks for the delivery date, though neither needs the other. Start both at once. After your fix, about how many milliseconds until the order shows?
import { useEffect, useState } from "react";import { fetchDelivery, fetchOrder } from "./api.js"; export default function OrderPage() { const [page, setPage] = useState(null); useEffect(() => { let ignore = false; async function load() { const order = await fetchOrder(); const delivery = await fetchDelivery(); if (!ignore) setPage({ order, delivery }); } load(); return () => { ignore = true; }; }, []); if (page === null) return <p>Loading your order…</p>; return <p>Order {page.order.id}: {page.order.items} items, arriving {page.delivery}</p>;}import { useEffect, useState } from "react";import { fetchDelivery, fetchOrder } from "./api.js"; export default function OrderPage() { const [page, setPage] = useState(null); useEffect(() => { let ignore = false; async function load() { const [order, delivery] = await Promise.all([fetchOrder(), fetchDelivery()]); if (!ignore) setPage({ order, delivery }); } load(); return () => { ignore = true; }; }, []); if (page === null) return <p>Loading your order…</p>; return <p>Order {page.order.id}: {page.order.items} items, arriving {page.delivery}</p>;}Promise.all starts both requests at once and waits for the slower one, the order's 500 ms. Before, fetchDelivery() only started after the order's await finished, at 500 ms, so the page waited 900 ms.
Here is the fixed delivery estimate from exercise 3. Change it to cancel the old request with an AbortController, and log Cancelled and the city when a request is cancelled. Click London, then Paris. What does the console print for London?
import { useEffect, useState } from "react";import { fetchDelivery } from "./api.js"; export default function DeliveryEstimate() { const [city, setCity] = useState("paris"); const [estimate, setEstimate] = useState(""); useEffect(() => { let ignore = false; fetchDelivery(city).then((text) => { if (!ignore) setEstimate(text); }); return () => { ignore = true; }; }, [city]); return ( <> <button onClick={() => setCity("london")}>London</button> <button onClick={() => setCity("paris")}>Paris</button> <p>Deliver to: {city}</p> <p>{estimate}</p> </> );}import { useEffect, useState } from "react";import { fetchDelivery } from "./api.js"; export default function DeliveryEstimate() { const [city, setCity] = useState("paris"); const [estimate, setEstimate] = useState(""); useEffect(() => { const controller = new AbortController(); fetchDelivery(city, { signal: controller.signal }) .then((text) => setEstimate(text)) .catch((error) => { if (error.name === "AbortError") console.log(`Cancelled ${city}`); }); return () => { controller.abort(); }; }, [city]); return ( <> <button onClick={() => setCity("london")}>London</button> <button onClick={() => setCity("paris")}>Paris</button> <p>Deliver to: {city}</p> <p>{estimate}</p> </> );}Clicking Paris runs the London run's cleanup, which aborts its controller. fetchDelivery (in api.js) clears London's timer and rejects with an AbortError, so the catch logs Cancelled london, and London never answers.
Check your understanding
For each question about timing, write down when each request starts and when it answers, then ask which setState runs last.
Question 1 of 8Why does the request go in
useEffect, and not straight in the component's body?Choose an answer to see the explanation.
Question 2 of 8The page starts on cake. You click Tea, then Cake 200 ms later. Which product does this effect leave on the page?
Read the code, then predictJSXuseEffect(() => { fetchProduct(productId).then((data) => { setProduct(data); }); }, [productId]); // Tea answers after 1500 ms, cake after 300 ms. // The page starts on cake. You click Tea, // then Cake 200 ms later, and wait.Choose an answer to see the explanation.
Question 3 of 8When does React call the cleanup function an effect returns?
Choose an answer to see the explanation.
Question 4 of 8This code uses the lesson's
fetchProduct. What does it print?Read the code, then predictconst controller = new AbortController(); const request = fetchProduct("tea", { signal: controller.signal }); controller.abort(); request.catch((error) => console.log(error.name));Choose an answer to see the explanation.
Question 5 of 8A page shows
Loading…until its product arrives after 600 ms, and only then renders<Reviews />, whose effect asks for reviews that take 400 ms. When do the reviews appear?Choose an answer to see the explanation.
Question 6 of 8The server answers a
fetchwith a 404 Not Found. What does the promisefetchreturned do?Choose an answer to see the explanation.
Question 7 of 8In development, the console shows
Fetch caketwice as soon as the page opens. Why?Choose an answer to see the explanation.
Question 8 of 8Which of these does a data library or framework add that a plain effect doesn't?
Choose an answer to see the explanation.
Key takeaways
- Fetch in an effect, not while rendering, and store the answer in state. The first render shows a loading state.
- Don't make the effect function async. Define an async function inside it and call it.
- When the thing you fetch can change, answers can arrive out of order. Clean up every run: an
ignoreflag drops the old answer, and anAbortControllercancels the old request. - Keep one status for loading, done, and error. Check
response.ok, becausefetchdoesn't reject on 404 or 500. - A child that renders only after its parent's data starts its request late: a waterfall. Render independent parts together.
- Effects have no cache, no sharing, and nothing on the server. Real apps use a framework or a data library for that.
Remember the one-liner.
Ask in an effect, clean up every run, show every state, and don't make requests wait in line.
Coming next: Suspense. It lets a part of the page wait for its code or data, and shows a loading state you describe once, around it.