Data fetching, race conditions and Suspense
Fetching in an effect has a race - a slow old response can overwrite a fast new one. How cleanup fixes it, and how Suspense and use() move loading states out of components.
After this lesson you can
- Explain the race condition in fetch-in-an-effect and fix it with cleanup or AbortController
- Describe how Suspense shows a fallback while a child waits for data
- Explain why the promise passed to use() must be cached, not created during render
The classic way to load data in a client component is an effect:
function Results({ query }: { query: string }) {
const [data, setData] = useState<string | null>(null);
useEffect(() => {
let ignore = false;
fetchResults(query).then((result) => {
if (!ignore) setData(result);
});
return () => { ignore = true; };
}, [query]);
return <List data={data} />;
}
The ignore flag is not decoration. Without it this component has a
race condition.
The race
The user types re, then react. Two requests are in flight. Nothing
guarantees they come back in the order they were sent: if the re
request is slower, it arrives last and overwrites the react results.
The screen now shows results for a query that is no longer in the box.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function fakeFetch(query) { await wait(query === "re" ? 60 : 10); // the older request is slower return `results for ${query}`;} // One component's effect, with or without the ignore flag.function makeEffect(screen, guarded) { return (query) => { let ignore = false; fakeFetch(query).then((result) => { if (!guarded || !ignore) screen.push(result); }); return () => { ignore = true; }; };} export async function run() { const out = {}; for (const guarded of [false, true]) { const screen = []; const effect = makeEffect(screen, guarded); let cleanup = effect("re"); // render with query "re" cleanup(); // query changes: React cleans up... cleanup = effect("react"); // ...then runs the new effect await wait(100); out[guarded ? "withIgnoreFlag" : "noCleanup"] = { updates: screen, finalScreen: screen[screen.length - 1], }; } return out;}The fix uses the cleanup rule from lesson 7: when query changes, React
runs the previous effect's cleanup before the new effect, so the earlier
request's ignore becomes true and its late response is dropped. An
AbortController does the same job and also cancels the network request:
create it in the effect, pass signal to fetch, call abort() in
cleanup, and ignore the resulting AbortError.
Effects also leave you to handle loading and error states, caching, and deduplication by hand, and they start fetching only after the component has rendered. That is why most applications fetch through a framework's data loading or a library such as TanStack Query or SWR, which solve the race and the caching once.
Suspense
Suspense moves "is it loaded yet?" out of the component. A component
that needs data that is not ready suspends; React then shows the
fallback of the nearest <Suspense> boundary above it, and renders the
component again when the data is ready.
function Profile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // React 19: suspends until resolved
return <h1>{user.name}</h1>;
}
<Suspense fallback={<Spinner />}>
<Profile userPromise={userPromise} />
</Suspense>
use(promise) returns the resolved value, suspends while the promise is
pending, and throws the rejection to the nearest error boundary (next
lesson). The idea is simple enough to model (this is a sketch, not
React's code): reading data that is not ready interrupts the render, the
boundary catches the interruption and renders the fallback, and settling
the promise triggers another attempt.
// read() throws the pending promise; a boundary catches it.function read(record) { if (record.status === "done") return record.value; throw record.promise;}function track(promise) { const record = { status: "pending", promise }; promise.then((value) => { record.status = "done"; record.value = value; }); return record;} async function suspenseBoundary(render, fallback, maxTries) { const log = []; for (let i = 0; i < maxTries; i++) { try { log.push(`content: ${render()}`); return log; } catch (thrown) { if (!(thrown instanceof Promise)) throw thrown; log.push(`fallback: ${fallback}`); await thrown; // retry once it settles await null; } } log.push("gave up: never left the fallback"); return log;} const loadUser = () => new Promise((r) => setTimeout(() => r("Ada"), 10)); export async function run() { const cached = track(loadUser()); // created once, outside render const good = await suspenseBoundary(() => read(cached), "Loading...", 3); const bad = await suspenseBoundary(() => read(track(loadUser())), "Loading...", 3); return { cachedPromise: good, promiseCreatedInRender: bad };}The second half of the model's output is why the promise must be
cached: a component that creates a new promise on every render hands
each retry a fresh, pending promise, so in the model it never leaves the
fallback. Real React is more forgiving in a narrow case (while replaying
a suspended render it reuses the first promise), but it logs "A component
was suspended by an uncached promise" in development and does not support
the pattern; in practice it means repeated requests and a fallback that
keeps coming back. Promises passed to use should come from a cache, a
library, a server component or a parent that created them once.
When content that is already on screen would suspend again (say, after a
tab switch), wrapping the update in startTransition tells React to keep
showing the old content instead of swapping back to the fallback.
Common mistakes
- No cleanup in a fetching effect. A slow, outdated response overwrites the current one, as the first example models.
- Creating the promise for
useduring render. Each new render attempt starts a new request; React warns about the uncached promise, and the fallback can keep coming back (the model shows the worst case). - One Suspense boundary around the whole page. The entire page shows a spinner for the slowest piece of data; place boundaries where a loading state makes sense.
- Forgetting the error path. A rejected promise read with
usegoes to an error boundary, so there must be one.
What interviewers ask
- What race condition can happen when fetching in
useEffect? — Responses can arrive out of order, so an older request overwrites newer data; ignore stale responses in cleanup or abort them. - What does Suspense do? — It shows a fallback while any component inside it is suspended waiting for data or code, then renders the content once it is ready.
Exercise · Pro
When each part of the page appears
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade