You've been in this situation before. You have a component that needs to fetch some data, which sounds like a no-brainer, right? Just use useEffect()
to load the data, set it into state and you're done. You slide the keyboard in front of you and start typing out your component:
// ...
interface Post {
image: string;
title: string;
link: string;
}
function App() {
const [releases, setReleases] = useState<null | Post[]>(null);
useEffect(() => {
fetch(targetUrl)
.then((response) => response.json())
.then((data) => {
const converted = extractThumbnails(data);
console.log("Got data!", converted);
setReleases(converted);
})
.catch((err) => {
console.error(err);
setReleases([]);
});
}, []);
// ...
}
At first glance it seems to work just fine when you load it up in the browser:
However, if you're running this example using React 18 in development and strict mode (like the above example), every useEffect
hook runs twice when the component is mounted.
Apparently, it is an all too common problem in React codebases to not correctly handle clean-up when using side effects. There's even an entire page dedicated to why you shouldn't use it for. So the React developers decided to force the useEffect
hook to fire twice in development mode to surface problems like these early and break things in an obvious way if not coded correctly.
I may not agree that the behaviour of hooks should change when switching between production and development modes, I do wholeheartedly agree that effect hooks should correctly return clean-up functions when necessary. So let's fix our example.
First off, let's think about why the example above might be problematic in the first place. If the effect runs twice, then two requests will fire and the last one to arrive will be the one to determine what kind of data will be shoved into the state. A race condition.
There are two ways to solve this problem:
- ignore the response from the first request; or
- cancel the previous request.
If you look at the documentation example on React's documentation page, the authors outline the first solution:
useEffect(() => {
let ignore = false;
fetch(targetUrl)
.then((response) => response.json())
.then((data) => {
if (!ignore) {
const converted = extractThumbnails(data);
setReleases(converted);
}
})
.catch((err) => {
console.error(err);
setReleases([]);
});
return () => {
ignore = true;
};
}, []);
While this does avoid the race condition, it still fires two requests, the first of which will always be ignored. This will waste bandwidth, of course, but it will prevent the state from being updated twice.
The other problem I have with this solution is that the if (!ignore)
check has to be on the last step of the promise chain, meaning the response will always be decoded. You could conditionally throw an error on every promise chain step, but forgetting to do that in an intermediate step will just waste more time on a throwaway result.
Let's look into how to implement the second solution, then. To cancel a request using fetch()
, we must create an instance of AbortController
and pass its signal
property into the configuration. We can then use the controller to call its abort()
method when the request needs to be cancelled:
useEffect(() => {
const controller = new AbortController();
fetch(targetUrl, { signal: controller: signal })
.then(response => response.json())
.then(data => {
const converted = extractThumbnails(data);
setReleases(converted);
})
.catch(err => {
if (err.name != 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, []);
When a request is cancelled, the promise rejects with a DOMException
whose name
property equals "AbortError"
. Since we can treat request cancellation as an expected error, we only report other errors to the console.
This now results in the following timeline:
The nice thing about this approach is that the promise chain will simply reject at whichever point abort()
gets called and doesn't require us to check a variable state at each point in the chain just to avoid wasted work. It also helps with readability — the returned function calls abort()
and the promise chain handles that case in the catch()
case, as it should.
Now that this problem is solved for the double useEffect
execution in dev mode, it also solves the problem of quickly switching between components on a slow network — components won't continue to request and download responses after they're unmounted. On a slow mobile connection, this could mean the difference between an OK experience and a REALLY bad one.
And here's the implementation with AbortController
:
Top comments (0)