Fetching Data: Races, Stale Responses, and Cleanup
The most common real bug in data-fetching components: a slower, earlier request's response arriving after a faster, later one, silently overwriting the correct data. Build a real fetch-driven component locally.
What you'll learn
- Explain how a race condition between two requests can overwrite correct data with stale data
- Implement a request-token guard that ignores out-of-order responses
- Build a real component with proper data-fetching, loading, and cleanup behavior on your own machine
Prerequisites
Explanation
A search box triggers a fetch on every keystroke. The user types "re", then quickly "react". Two requests are now in flight: one for "re", one for "react". There's no guarantee they resolve in the order they were sent — if the network happens to return the "re" response after the "react" response, the UI ends up showing results for "re" even though the input clearly reads "react". This is a race condition, and it is one of the single most common real bugs in data-fetching components — not an exotic edge case, but a routine consequence of fast typing on an ordinary connection.
The fix is a request-token guard: before starting a new request, record a token unique to that specific request (a simple incrementing counter is enough). When the request resolves, only apply its result if that token still matches "the latest request that was started." A response from a request that's no longer the latest one is, by definition, stale — its result is real data, correctly fetched, and still wrong to display, because something newer superseded it before it arrived.
let latestRequestId = 0;
function fetchResults(query) {
const thisRequestId = ++latestRequestId;
fetchFromServer(query).then((data) => {
if (thisRequestId === latestRequestId) {
setResults(data); // only apply if nothing newer has started since
}
});
}
This is exactly what an effect's cleanup function is for in a data-fetching effect: mark the previous request as stale before starting a new one. Every real production data-fetching hook (React Query, SWR, and the pattern React's own docs recommend by hand) implements some version of this guard — it is not an advanced-only concern, it's the baseline correctness requirement for fetching data driven by anything that can change quickly, like a search input or a fast-clicking filter.
This lesson's guided local lab builds a real, race-condition-safe course list component that fetches and filters data locally, on your own machine.
Example
A simulated race condition between two requests, and the request-token guard that correctly resolves it -- deterministic, no real network call.
let latestRequestId = 0;
let lastAppliedResult = null;
function simulateFetch(query, resolveOrder) {
const thisRequestId = ++latestRequestId;
// "resolveOrder" simulates network timing: lower numbers resolve first.
return { thisRequestId, query, resolveOrder };
}
function applyIfStillLatest(response) {
if (response.thisRequestId === latestRequestId) {
lastAppliedResult = response.query;
}
}
const reqA = simulateFetch("re", 1); // started first
const reqB = simulateFetch("react", 2); // started second, becomes "latest"
// Simulate B's response arriving first, then A's arriving late:
applyIfStillLatest(reqB);
applyIfStillLatest(reqA); // stale -- ignored, because latestRequestId has moved on
console.log(lastAppliedResult); // "react" -- correct, even though A's response arrived lastTry it yourself
Remove the applyIfStillLatest guard (call the assignment directly instead) and see the wrong, stale result win.
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Guided exercise
Guided exercise
Write isStaleResponse(responseRequestId, latestRequestId) that returns true if a response's request id no longer matches the latest request id (meaning it should be ignored), false if it's still current.
Checks: correctly identifies a stale response · correctly identifies a current response
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Independent exercise
Independent exercise
Write a function createRequestGuard() returning { start(), isCurrent(id) }: start() increments and returns a new request id (the 'latest'), and isCurrent(id) returns whether the given id still matches the latest one started.
Checks: the latest request id is current · an earlier request id is correctly stale
Code editor. Press Escape then Tab to leave the editor if keyboard focus becomes trapped. Press Control+Shift+M inside the editor to toggle Tab-key focus trapping.
Stuck? Get a hint.
Guided local lab
Build a Race-Condition-Safe Course List Locally
Runs on your computerExtend a real local React project with a component that fetches a mock course list based on a search input, correctly guarding against race conditions and cleaning up on unmount, using the four UI states from the previous lesson.
Required tools
- Node.js (20.x LTS or newer)
- npm (10.x (bundled with Node.js))
Setup
- Reuse the Vite + React project from the previous lesson's lab (or run `npm create vite@latest course-search -- --template react` for a fresh one).
- Add the mock API file below as `src/mockApi.js`.
- Replace `src/App.jsx` with the starter file below.
- Run `npm run dev` and open the printed local URL.
Project structure
course-search/
src/
App.jsx
mockApi.js
main.jsx
package.jsonStarter files
src/mockApi.js
const ALL_COURSES = [
{ id: 1, title: "HTML & CSS Fundamentals" },
{ id: 2, title: "JavaScript Fundamentals" },
{ id: 3, title: "TypeScript Foundations" },
{ id: 4, title: "React Application Development" },
];
// Simulates a real network call with a random delay, so responses can
// genuinely arrive out of order -- deliberately, to make the race condition
// this lab guards against reproducible.
export function searchCourses(query) {
const delayMs = Math.random() * 800;
const results = ALL_COURSES.filter((c) =>
c.title.toLowerCase().includes(query.toLowerCase()),
);
return new Promise((resolve) => setTimeout(() => resolve(results), delayMs));
}src/App.jsx
import { useEffect, useState } from "react";
import { searchCourses } from "./mockApi";
export default function App() {
const [query, setQuery] = useState("");
const [items, setItems] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
// TODO: guard against race conditions using a request-token or an
// "ignore" flag set by the cleanup function, so a slow, stale response
// can never overwrite a newer, faster one.
setIsLoading(true);
searchCourses(query)
.then((results) => {
setItems(results);
setIsLoading(false);
})
.catch((err) => {
setError(String(err));
setIsLoading(false);
});
}, [query]);
const uiState = isLoading ? "loading" : error ? "error" : items.length === 0 ? "empty" : "success";
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search courses..."
aria-label="Search courses"
/>
{uiState === "loading" && <p>Loading...</p>}
{uiState === "error" && <p role="alert">Something went wrong.</p>}
{uiState === "empty" && <p>No courses match your search.</p>}
{uiState === "success" && (
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
)}
</div>
);
}Requirements
- Typing quickly never leaves the list showing results for an earlier, now-outdated query
- The effect's cleanup function marks the in-flight request as no longer relevant before the next one starts
- The four UI states (loading, error, empty, success) are all reachable and visually distinct
- No console errors or warnings appear, including no 'state update on an unmounted component' warning
Commands to run
Start the dev server
npm run dev
Expected behavior
Typing a query quickly (e.g. 'r' then 'rea' then 'react' within under a second) always ends up showing results for 'react' specifically, never briefly or permanently showing results for 'r' or 'rea', regardless of the random simulated network delay.
Verify it yourself
Type quickly into the search box: r, then rea, then react, within about a secondExpected: The final displayed list always matches 'react' -- open the console and confirm no stray state updates after the component would be considered stale
Clear the search box entirelyExpected: All four courses are shown (an empty query matches everything via includes(''))
Type a query that matches nothing, like 'zzz'Expected: The empty state message appears, not the error state
Troubleshooting
- Occasionally the wrong, outdated results flash on screen — Confirm the effect's cleanup function actually sets an 'ignore' flag (or checks a request id) that the .then() callback reads before calling setItems -- without it, every in-flight promise still resolves and unconditionally overwrites state.
- React warns about updating state on an unmounted component — The same ignore-flag/request-id guard that fixes race conditions also fixes this -- the cleanup function should prevent the stale .then() callback from calling setState at all once it's no longer relevant.
Stuck? Get a hint.
Extension challenge
Add a 300ms debounce so a fetch only starts after the user pauses typing, rather than on every keystroke -- measure how much it reduces the number of in-flight requests during fast typing.
When you've verified this locally, use the "Mark lesson complete" button below to record your progress.
Common mistakes
- Fetching on every keystroke with no guard against out-of-order responses, letting a slow early request silently overwrite a fast later one.
- Forgetting the cleanup function entirely in a data-fetching effect, causing both race conditions and 'update on unmounted component' warnings.
- Assuming race conditions are rare -- on a real network with variable latency, they are a routine, frequent occurrence for any fast-changing input.
Knowledge check
Takeaway
Race conditions between out-of-order responses are a routine, frequent bug in real data-fetching components, not an edge case -- a request-token or ignore-flag guard, set by the effect's cleanup function, is the baseline fix.
Summary
This lesson implemented a request-token guard against race conditions in browser exercises, then built a real, race-condition-safe search-driven component with all four UI states in a local React project via the guided local lab.
References
Your notes
Notes save automatically.
Finished this lesson?
Mark it complete to track your progress and schedule a future review.
AI tutor
The optional AI tutor isn't enabled in this deployment. All lessons, exercises, quizzes, and search work fully without it.