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;
}
}
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.
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:
props.id
will cause a re-render,active
to false
,active
set to false
, the now-stale requests won't be able to update our stateYou'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 AbortControllerAgain, 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') {
}
}
}, 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:
fetch
via the options argument,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.
RetroSearch is an open source project built by @garambo | Open a GitHub Issue
Search and Browse the WWW like it's 1997 | Search results from DuckDuckGo
HTML:
3.2
| Encoding:
UTF-8
| Version:
0.7.4