Fixing Race Conditions in React with useEffect

Max Rozen
@RozenMD

So you've got a component that fetches data in React. The component accepts an id as a prop, uses the id to fetch data with useEffect, and display it.

You notice something strange: sometimes the component displays correct data, and sometimes it's invalid, or out of date.

Chances are, you've run into a race condition.

You would typically notice a race condition (in React) when two slightly different requests for data have been made, and the application displays a different result depending on which request completes first.

In fetching data with useEffect, we wrote a component that could have a race condition, if id changed fast enough:

import React, { useEffect, useState } from 'react';
export default function DataDisplayer(props) {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
setData(newData);
};
fetchData();
}, [props.id]);
if (data) {
return <div>{data.name}</div>;
} else {
return null;
}
}

It might not seem obvious that the snippet above is vulnerable to race conditions, so I cooked up a CodeSandbox to make it more noticeable (I added a random wait period of up to 12 seconds per request).

You can see the intended behaviour by clicking the "Fetch data!" button once: a simple component that displays data in response to a single click.

Things get a bit more complicated if you rapidly click the "Fetch data!" button several times. The app will make several requests which finish randomly out of order. The last request to complete will be the result displayed.

The updated DataDisplayer component now looks like this:

export default function DataDisplayer(props) {
const [data, setData] = useState(null);
const [fetchedId, setFetchedId] = useState(null);
useEffect(() => {
const fetchData = async () => {
setTimeout(async () => {
const response = await fetch(
`https://swapi.dev/api/people/${props.id}/`
);
const newData = await response.json();
setFetchedId(props.id);
setData(newData);
}, Math.round(Math.random() * 12000));
};
fetchData();
}, [props.id]);
if (data) {
return (
<div>
<p style={{ color: fetchedId === props.id ? 'green' : 'red' }}>
Displaying Data for: {fetchedId}
</p>
<p>{data.name}</p>
</div>
);
} else {
return null;
}
}

Fixing the useEffect race condition

There are a couple of approaches we can take here, both taking advantage of useEffect’s clean-up function:

  • If we're okay with making several requests, but only rendering the last result, we can use a boolean flag.

  • Alternatively, if we don't have to support users on Internet Explorer, we can use AbortController.

useEffect Clean-up Function with Boolean Flag

First, the gist of our fix in code:

useEffect(() => {
let active = true;
const fetchData = async () => {
setTimeout(async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
if (active) {
setFetchedId(props.id);
setData(newData);
}
}, Math.round(Math.random() * 12000));
};
fetchData();
return () => {
active = false;
};
}, [props.id]);

This fix relies on an often overlooked sentence in the React Hooks API reference:

Additionally, if a component renders multiple times (as they typically do), the previous effect is cleaned up before executing the next effect

In the example above:

  • changing props.id will cause a re-render,
  • every re-render will trigger the clean-up function to run, setting active to false,
  • with active set to false, the now-stale requests won't be able to update our state

You'll still have a race-condition in the sense that multiple requests will be in-flight, but only the results from the last one will be used.

It's likely not immediately obvious why the clean-up function in useEffect would fix this issue. I'd recommend you see this fix in action, by checking out the CodeSandbox (I also added a counter to track the number of active requests, and couple of helper functions).

useEffect Clean-up Function with AbortController

Again, let's start with the code:

useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
setTimeout(async () => {
try {
const response = await fetch(`https://swapi.dev/api/people/${id}/`, {
signal: abortController.signal,
});
const newData = await response.json();
setFetchedId(id);
setData(newData);
} catch (error) {
if (error.name === 'AbortError') {
// Aborting a fetch throws an error
// So we can't update state afterwards
}
// Handle other request errors here
}
}, Math.round(Math.random() * 12000));
};
fetchData();
return () => {
abortController.abort();
};
}, [id]);

As with the previous example, we've used the fact that React runs the clean-up function before executing the next effect. You can check out the CodeSandbox too (this time we're not counting the number of requests as there can only be one at any time).

However, this time we're:

  • initialising an AbortController at the start of the effect,
  • passing the AbortController.signal to fetch via the options argument,
  • catching any AbortErrors that get thrown (when abort() is called, the fetch() promise rejects with an AbortError, see MDN reference), and
  • calling the abort function inside the clean-up function

With this example, we're faced with the following trade-off: drop support for Internet Explorer/use a polyfill, in exchange for the ability to cancel in-flight HTTP requests.

Personally, I'm lucky enough to work for a company where Internet Explorer is no longer supported, so I'd prefer to avoid wasting user bandwidth and use AbortController.

Hey! Tired of infinite re-renders when using useEffect?

I've started teaching a free mini-course on useEffect via email. I'd love to hear what you think!

You'll learn how to fetch data with useEffect, how to use the dependency array, even how to prevent infinite re-renders with useCallback.

    Join 1,555 React developers that have signed up so far!