First let's talk about the problem we're trying to solve here
If you're working with React it's almost impossible that you never saw this error log in your browser console
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount
method.
in TextLayerInternal (created by Context.Consumer)
in TextLayer (created by PageInternal) index.js:1446
d/console[e]
not gonna lie this is probably one of the most painful things to get your head around after you have gained a good understanding of how component lifecycle works. This error basically means you are using an asynchronous block of code that has some state mutation inside it (By state mutation I mean setState ) thus resulting in a memory leak
Although in most cases it's harmless, there's still a possibility of un-optimized heap usage, chances of your code breaking, and all the other good stuff that goes around with it.
Now let's talk Solutions
well, there are a couple of ways we can tackle this problem, one of the most popular solutions is to use any logic that checks if the component is still mounted in the component tree and make any state change operation only then and you'd think that would just solve your problems right? right ??
well.... kinda, I mean let's take a step back and think about a very famous hook useIsMounted
now Think about a scene where you are making an API call on the mount of a component and using this hook you'll change the state only if it's still mounted
const isMounted = useIsMounted();
const [value, setValue] = useState();
useEffect(() => {
fetch('some resource url')
.then((data) => {
return data.json();
})
.then((data) => {
if (isMounted()) {
setValue(data);
}
});
}, [input]);
Looks like a perfectly okay piece of code that totally doesn't throw any errors right? well yeah I mean this works!!
But
Aren't you still making the fetch call?
Aren't you still fulfilling the promise? what you clearly don't need to do if the component is already unmounted right?
And depending on how API-driven your application is avoiding to fulfill all the network requests might benefit you in ways that you never considered
So how can we do that? well we can just cancel the ongoing request and as it turns out, modern browsers have had this feature for quite some time
The AbortController Interface allows you to, ya know just abort any web request.
As of now browser's fetch API and Axios officially supports AbortControllers
Now we can just be done with this here, but just to make it look a little bit cooler let's make a custom hook out of this and look at a live example
useAbortedEffect hook to cancel any network requests when the component unmounts
import { useEffect } from 'react';
const useAbortedEffect = (
effect: (signal: AbortSignal) => Function | void,
dependencies: Array<any>
) => {
useEffect(() => {
const abortController = new AbortController();
const signal = abortController.signal;
const cleanupEffect = effect(signal);
return () => {
if (cleanupEffect) {
cleanupEffect();
}
abortController.abort();
};
}, [...dependencies]);
};
export default useAbortedEffect;
Now let's break things down to understand what's going on. our custom effect takes a callback function that accepts an AbortSignal param, and a dependency array as an argument just like any other effect hook, inside our useEffect we instantiate an AbortController and pass the signal into our effect callback so that any network request we want to make should be able to get this signal. this would help us to control the execution cycle of all the APIs that will be declared in our effect callback. and in the unmount callback of our useEffect we just abort the controller and any network call that is going on in our effect will be canceled from the browser
Let's take an example to appreciate this hook
In this example, we'll be creating 3 nested routes using React router's Outlet API to make each page mount and re-mount consecutively so that we can monitor the network tab
import { Outlet, useNavigate } from 'react-router-dom';
const Home = () => {
const navigate = useNavigate();
return (
<div>
Home Page
<div className="column">
<button onClick={() => navigate('/first')}>First</button>
<button onClick={() => navigate('/second')}>Second</button>
<button onClick={() => navigate('/third')}>Third</button>
<Outlet />
</div>
</div>
);
};
export default Home;
In each of our pages first, second & third we will use our custom hook to fire an API and pass the signal argument to the signal properties of fetch and Axios in order to control the request (remember this step is mandatory because any request that doesn't have this signal would not be canceled)
The First page component would look something like this
//example with axios
useAbortedEffect(
(signal) => {
axios
.get('https://jsonplaceholder.typicode.com/posts', {
signal
})
.then((data) => {
console.log('First API call');
})
.catch((e: any) => {
if (e.name === 'CanceledError') {
console.log('First API aborted');
}
});
},
[]
);
return (
<div>
First Page
<div
style={{
display: 'flex',
gap: '10px',
marginTop: '20px'
}}>
<button onClick={() => setCount(count + 1)}>Click </button>
<span>Count : {count}</span>
</div>
</div>
);
Now since I'm using a JSON placeholder as an endpoint suffice to say noticing any pending state of the network call would be tricky so let's simulate a slower network
In the dev-tool open up the network tab and select Slow 3G
from the networks dropdown (I'm using Chrome)
Now after starting the application start clicking on the First, Second & third link in the exact order and look at the network tab
and since we had used console.log at each step in our custom effect let's look at the console too
As you can see in after consecutively mounting and remounting the First and Second pages all the pending requests got canceled because of the Abort signal and we can see the exact console logs as well. This would work similarly to debouncing in javascript but instead of debouncing with timers during the event loop, we'll be debouncing network requests in the browser itself.
What you can achieve with this hook?
Well depending on how you have architected your application and how much API-driven it is , potentially you could
Avoid memory leaks in the components
Make Atomic API transactions with respect to your Component
Make less number of API calls altogether.
Github repo for the example
Do comment on the article so that I can make this better and improve any mistakes I have made, thanks in advance.
Feel free to follow me on other platforms as well
Top comments (2)
Thank you for this hook. I wonder if you have an idea about a trouble I'm struggling with : if I have a loading logic, with something like
finally {
setLoading(false)
}
Then with the "double render" in React 18, there is a first call which is canceled, so far so good, but this finally code happens after the second call has started, so the truthy loading of the second call is overriden and we don't see it. Of course this problem occurs only in dev mode, but it's annoying.
I can fix this with a "isMounted" logic but I thought we were done with this kind of things and it works only inside the useEffect (sometimes I need to make another call in my UI, so move it in a function called at will).
Hi @samintegrateur I think with React 18 a lot of the older paradigms have changed , in fact React team has highly suggested not to use useEffect as a lifecycle mechanism,(check this link ) for maintaining api calls i'd highly suggest you use something like SWR or react query , you can also implement the cancellation with them but they do a ton of heavy lifting themselves in terms of these kind of tropes .