How to Prevent Race Conditions in React Data Fetching
This article explains what race conditions are in front‑end web development, demonstrates the issue with a React demo fetching articles, and walks through step‑by‑step solutions using custom hooks, useEffect cleanup, and AbortController to safely cancel outdated requests and avoid stale data rendering.
Introduction Race conditions, translated from the English term "race conditions," occur when multiple asynchronous requests compete and the first request to finish does not necessarily correspond to the first request sent. In front‑end web development, this often appears when fetching data and rendering it in the browser.
Fetching Data
Below is a small demo that fetches article data and renders it on the page.
App.tsx <code>import React from 'react';
import { Routes, Route } from 'react-router-dom';
import Article from './Article';
function App() {
return (
<Routes>
<Route path="/articles/:articleId" element={<Article />} />
</Routes>
);
}
export default App;</code> Article.tsx <code>import React from 'react';
import useArticleLoading from './useArticleLoading';
const Article = () => {
const { article, isLoading } = useArticleLoading();
if (!article || isLoading) {
return <div>Loading...</div>;
}
return (
<div>
<p>{article.id}</p>
<p>{article.title}</p>
<p>{article.body}</p>
</div>
);
};
export default Article;</code>In the
Articlecomponent the data request is encapsulated in a custom hook
useArticleLoading. The component displays either the fetched data or a loading state.
useArticleLoading.tsx <code>import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
interface Article {
id: number;
title: string;
body: string;
}
function useArticleLoading() {
const { articleId } = useParams<{ articleId: string }>();
const [isLoading, setIsLoading] = useState(false);
const [article, setArticle] = useState<Article | null>(null);
useEffect(() => {
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`)
.then(response => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.finally(() => {
setIsLoading(false);
});
}, [articleId]);
return { article, isLoading };
}
export default useArticleLoading;</code>The hook manages the loading state and the data request.
Race‑Condition Scenario
Consider the following sequence:
Visit
/articles/1– the browser starts a request for article 1, but the network fails, so the response never arrives.
Immediately navigate to
/articles/2– the browser starts a request for article 2, which succeeds quickly and renders.
Later the request for article 1 finally returns, and its data overwrites the current view, causing the page to show article 1 instead of article 2.
This happens because network latency is unpredictable; the first request does not guarantee the first response.
Solution Using a Cancellation Flag
One simple fix is to ignore responses that are no longer needed. In React this can be done with a flag inside
useEffect:
useArticlesLoading.tsx <code>useEffect(() => {
let didCancel = false;
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`)
.then(response => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
if (!didCancel) {
setArticle(fetchedArticle);
}
})
.finally(() => {
setIsLoading(false);
});
return () => {
didCancel = true;
};
}, [articleId]);</code>The cleanup function sets
didCancelto
true, preventing
setArticlefrom running for stale requests.
AbortController Solution
While the flag approach works, it still lets the browser finish the request, wasting resources.
AbortControllercan actively cancel the request.
<code>useEffect(() => {
const abortController = new AbortController();
setIsLoading(true);
fetch(`https://get.a.article.com/articles/${articleId}`, {
signal: abortController.signal,
})
.then(response => {
if (response.ok) {
return response.json();
}
return Promise.reject();
})
.then((fetchedArticle: Article) => {
setArticle(fetchedArticle);
})
.catch(() => {
// handle abort or other errors
})
.finally(() => {
setIsLoading(false);
});
return () => {
abortController.abort();
};
}, [articleId]);</code>Calling
abortController.abort()terminates the request, freeing resources. Errors from the abort can be caught and handled separately.
Stopping Other Promises
AbortControllercan also be used to cancel generic promises. Example:
<code>function wait(time: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve();
}, time);
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject();
});
});
}
const abortController = new AbortController();
setTimeout(() => abortController.abort(), 1000);
wait(5000, abortController.signal)
.then(() => console.log('5 seconds passed'))
.catch(() => console.log('Waiting was interrupted'));
</code>This demonstrates how a signal can be passed to any asynchronous operation to make it cancellable.
AbortController Compatibility
Compatibility chart (excluding IE) shows that modern browsers fully support
AbortController:
Conclusion
The article discussed race conditions in React, explained why they occur, and presented two main mitigation strategies: a simple cancellation flag and the more robust
AbortController. It also showed how
AbortControllercan be applied to other asynchronous tasks, encouraging developers to choose the solution that best fits their project.
Tencent IMWeb Frontend Team
IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.
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.