How to Prevent Race Conditions When Fetching Data in React
This article explains why asynchronous data fetching in React can cause race conditions that lead to flickering or mismatched content, and presents several practical solutions—including component remounting, data validation, cleanup effects, and request cancellation—to ensure reliable UI updates.
In some scenarios, frontend developers may write code that passes tests but causes unexpected issues at runtime, such as random data display, mismatched search results, or incorrect tab navigation content.
Promise Overview
Before diving in, let’s review what a Promise is and why it’s needed. JavaScript normally executes code synchronously step‑by‑step. A Promise is one of the few asynchronous mechanisms that lets you start a task and immediately move on without waiting for its completion; the Promise notifies you when the task finishes.
Data fetching is the most common use case for Promises, whether using
fetchor third‑party libraries like
axios. The behavior is the same.
<code>console.log('first step'); // will log FIRST
fetch('/some-url') // create promise here
.then(() => {
// wait for Promise to be done
// log stuff after the promise is done
console.log('second step'); // will log THIRD (if successful)
})
.catch(() => {
console.log('something bad happened'); // will log THIRD (if error happens)
})
console.log('third step'); // will log SECOND</code>The process is:
fetch('/some-url')creates a Promise, then
.thenand
.catchhandle the result. To fully master Promises you need to read further documentation.
Promise and Race Conditions
Promises can introduce race conditions. Consider a simple page with a left‑hand tab column and a right‑hand content area that displays data fetched for the selected tab. Rapidly switching tabs can cause the right side to flicker and show seemingly random data.
The page consists of two parts: a root
Appcomponent that manages the
pagestate and renders navigation buttons plus a
Pagecomponent, and the
Pagecomponent that fetches data based on the
idprop.
<code>const App = () => {
const [page, setPage] = useState("1");
return (
<>
{/* left column buttons */}
<button onClick={() => setPage("1")}>Issue 1</button>
<button onClick={() => setPage("2")}>Issue 2</button>
{/* the actual content */}
<Page id={page} />
</>
);
};</code> <code>const Page = ({ id }) => {
const [data, setData] = useState({});
const url = `/some-url/${id}`;
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(r => {
// save data from fetch request to state
setData(r);
});
}, [url]);
return (
<>
<h2>{data.title}</h2>
<p>{data.description}</p>
</>
);
};</code>The race condition occurs when the first
fetchhas not returned yet and the user switches tabs again. The first request finishes later, updates the state of a component that is now displaying a different tab, and then the second request finishes, causing the UI to flicker between stale and fresh data.
If the second request finishes before the first, the opposite effect occurs: the new page appears correctly, then is overwritten by data from the previous page.
Solution: Force Remount
One way to avoid the race condition is to unmount the previous component and mount a new one when the tab changes, using conditional rendering:
<code>const App = () => {
const [page, setPage] = useState('issue');
return (
<>
{page === 'issue' && <Issue />}
{page === 'about' && <About />}
</>
);
};</code>Each component fetches its own data in
useEffect. Because the previous component is unmounted, its pending Promise can no longer update state, eliminating the race.
<code>const About = () => {
const [about, setAbout] = useState();
useEffect(() => {
fetch("/some-url-for-about-page")
.then(r => r.json())
.then(r => setAbout(r));
}, []);
...
};</code>The key is that changing
{page==='issue' && <Issue/>}causes React to unmount
Issueand mount
About, so stale callbacks are discarded.
Solution: Discard Wrong Data
A friendlier approach is to verify that the data returned by
.thenmatches the currently selected
idbefore updating state, using a
refto hold the latest id:
<code>const Page = ({ id }) => {
const ref = useRef(id);
useEffect(() => {
ref.current = id;
fetch(`/some-data-url/${id}`)
.then(r => r.json())
.then(r => {
if (ref.current === r.id) {
setData(r);
}
});
}, [id]);
};</code>If the response does not contain an
id, you can compare the request URL instead.
<code>useEffect(() => {
ref.current = url;
fetch(`/some-data-url/${id}`)
.then(result => {
if (result.url === ref.current) {
result.json().then(r => setData(r));
}
});
}, [url]);</code>Solution: Discard Previous Data via Cleanup
Another method is to use the cleanup function of
useEffectto ignore results from previous renders:
<code>useEffect(() => {
let isActive = true;
fetch(url)
.then(r => r.json())
.then(r => {
if (isActive) {
setData(r);
}
});
return () => {
isActive = false;
};
}, [url]);</code>Solution: Cancel Previous Requests
You can abort ongoing fetches with
AbortControllerand cancel them in the cleanup function:
<code>useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(r => setData(r))
.catch(error => {
if (error.name === 'AbortError') {
// ignore abort errors
} else {
// handle real errors
}
});
return () => {
controller.abort();
};
}, [url]);</code>Does Async/Await Change Anything?
Async/await is just syntactic sugar for Promises; it does not eliminate race conditions. The same solutions apply, only the syntax differs.
KooFE Frontend Team
Follow the latest frontend updates
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.