Frontend Development 16 min read

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.

KooFE Frontend Team
KooFE Frontend Team
KooFE Frontend Team
How to Prevent Race Conditions When Fetching Data in React

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

fetch

or 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

.then

and

.catch

handle 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

App

component that manages the

page

state and renders navigation buttons plus a

Page

component, and the

Page

component that fetches data based on the

id

prop.

<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

fetch

has 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

Issue

and mount

About

, so stale callbacks are discarded.

Solution: Discard Wrong Data

A friendlier approach is to verify that the data returned by

.then

matches the currently selected

id

before updating state, using a

ref

to 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

useEffect

to 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

AbortController

and 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.

ReactPromiseuseEffectrace conditiondata-fetchingAbortController
KooFE Frontend Team
Written by

KooFE Frontend Team

Follow the latest frontend updates

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.