Fetching data
Data fetching is the classic effect. The standard shape tracks three pieces of state, because a request is always in exactly one of three situations:
function Profile({ userId }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { setLoading(true); setError(null); fetch("/api/users/" + userId) .then(res => { if (!res.ok) throw new Error("HTTP " + res.status); return res.json(); }) .then(setData) .catch(err => setError(err.message)) .finally(() => setLoading(false)); }, [userId]); if (loading) return <p>Loading…</p>; if (error) return <p>Error: {error}</p>; return <h1>{data.name}</h1>; }
fetch and promises are straight from Advanced JavaScript. The React part is just: run in an effect keyed on [userId], store the outcome in state, render from state with the early-return pattern from lesson 5-3.
The race condition
Suppose userId changes from 1 to 2 quickly. Two requests are now in flight, and the response for 1 might arrive after the response for 2, overwriting fresh data with stale data. Networks reorder responses all the time.
The fix uses cleanup from lesson 7-2. Each effect run gets its own ignore flag, and the cleanup flips it when the effect becomes outdated:
useEffect(() => { let ignore = false; fetch("/api/users/" + userId) .then(res => res.json()) .then(json => { if (!ignore) setData(json); }); return () => { ignore = true; }; }, [userId]);
When userId changes, React cleans up the old effect, setting the OLD run's ignore to true, so its late response is thrown away. Only the newest run can still write to state.
Watching the race, then the fix
fakeFetch resolves after a given number of milliseconds, so the slow old query "re" lands after the fast new query "react".
function fakeFetch(query, ms) { return new Promise(resolve => setTimeout(() => resolve("results for " + query), ms) ); } async function search(query, ms) { const result = await fakeFetch(query, ms); console.log("screen shows: " + result); } let latest = null; async function searchFixed(query, ms) { latest = query; const result = await fakeFetch(query, ms); if (latest !== query) return; // stale, ignore console.log("screen shows: " + result); } async function main() { console.log("-- buggy: slow old request overwrites the new one --"); await Promise.all([search("re", 300), search("react", 100)]); console.log("-- fixed: stale responses are ignored --"); await Promise.all([searchFixed("re", 300), searchFixed("react", 100)]); } main();
Output
-- buggy: slow old request overwrites the new one -- screen shows: results for react screen shows: results for re -- fixed: stale responses are ignored -- screen shows: results for react
In the buggy phase the last line wins the screen, and it is the old query. Both requests succeeded, both handlers ran correctly, and the user still ends up looking at results for a search they have already moved past.
The fix works because latest = query runs synchronously when the search starts, so by the time "re" resolves, latest is "react" and the guard returns early. Nothing cancels the request, and the stale result is simply not allowed to reach the screen.
That distinction matters. Discarding a response is enough to fix the UI, and if you also want to stop the network work itself, that is AbortController, which is a separate tool with the same cleanup shape.
The module-level latest variable stands in for React's per-run ignore flag, and the React version is actually cleaner. Each effect run closes over its own ignore, so there is no shared variable to reason about and no need to compare query strings.
Note that typing quickly is not an edge case, it is the normal way people use a search box. Any effect that fetches on a changing dependency has this race, so the guard belongs there from the first draft rather than as a later fix.
async/await in effects
You know async/await from Advanced JavaScript, and the fetch chain above reads more naturally with it. But there is a rule: the effect function itself cannot be async. The reason is the cleanup contract from lesson 7-2, whatever an effect returns is treated as its cleanup function, and an async function always returns a promise, not a function. React would try to call the promise at cleanup time and warn.
The standard shape defines an inner async function and calls it:
useEffect(() => { let ignore = false; async function load() { const res = await fetch("/api/users/" + userId); const json = await res.json(); if (!ignore) setData(json); } load(); return () => { ignore = true; }; }, [userId]);
The outer function stays plain, so its return still hands React a real cleanup, and the ignore flag guards against the race exactly as before.
When the old request's ignore flag flips
It becomes true when React runs the old effect's cleanup, which happens because userId changed or the component unmounted.
Each effect run closes over its own ignore variable, declared fresh at the top of the callback. React calls the returned cleanup exactly when that run becomes outdated, so flipping the flag there means the late response cannot touch state.
The ordering is what makes it airtight. Cleanup runs before the new effect starts, so at the moment the second request begins, the first run's flag is already true and there is no window where both runs can write.
Note that the flag is per run rather than per component, which is why a shared let ignore outside the effect would not work. It would be one variable for every run, and the newest run would reset the very flag the old run needs to stay true.
The pattern also covers unmount, which is the case people forget. Navigating away mid-request would otherwise call setData on a component that is gone, and the same cleanup handles it with no extra code.
The three states a fetch needs
The trio is loading, error, and data: is the request still in flight, did it fail and with what message, and what was the successful result.
Render order follows from them, so if loading show a spinner, else if error show the message, else render from data. That is the early-return pattern from lesson 5-3 applied twice, and it guarantees the success branch can assume data exists.
They are mutually exclusive by construction rather than by accident, since finally clears loading and the catch sets error only on failure. Getting that wrong produces the classic crash where loading is false, data is still null, and the success branch reads data.name.
| State | Set when | Renders |
|---|---|---|
loading true | the effect starts | a spinner or skeleton |
error set | the request or status check failed | the message and a retry |
data set | the response parsed | the real UI |
Resetting loading to true and error to null at the top of the effect is the step most first drafts miss. Without it, switching to a new userId after a failure shows the old error next to the new request, which reads as a page that refuses to recover.
Real apps often move this trio into a data-fetching library, which handles caching and retries on top of it. The states underneath are always these three, so the pattern is worth writing by hand once before reaching for the abstraction.
Why an async effect callback is wrong
Writing useEffect(async () => { ... }, [userId]) breaks because an async function returns a promise, and useEffect treats the return value as the cleanup function, so React receives a promise where it expects a function.
The effect's return value has exactly one meaning, which is the cleanup function from lesson 7-2. An async function always returns a promise no matter what its body returns, so the contract breaks and React warns about it.
The fix is to define an inner async function, call it, and keep the outer effect synchronous so it can still return a real cleanup. Two extra lines buy back both await syntax and the cleanup slot.
Note that the warning fires even for effects that need no cleanup, which is React refusing to guess. It cannot tell a promise you meant to ignore from a cleanup you got wrong, so it flags every case.
There is a second reason the inner-function shape is better, beyond satisfying the contract. The ignore flag has to be declared and flipped in the synchronous outer scope, and having the async work in its own function makes the guard's placement obvious rather than tangled with the awaits.