The problem
State updates that are run inside useEffect are problematic when the component running said useEffect is no longer mounted on the DOM since they cause memory leaks and race conditions. The way this is often guarded against is by putting a check in place to only update the state when the component is mounted, like so:
useEffect(() => {
// we're certain component has mounted
let mounted = true;
const fetchSomeData = async () => {
setFetchIsPending(true)
const freshData = await fetchStuffUsingApi()
// conditionally update state
if (mounted) {
setData(freshData)
setFetchIsPending(false)
}
}
fetchSomeData()
// cleanup - component is unmounting
return () => mounted = false
}, [*dependencies*]);
This way we're sure to update the data and loading states only when the component is mounted, preventing the following, quite common, error:
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 a useEffect cleanup function.
The solution
While doing something along the lines of the example above is fine, it's not very DRY when the same logic is repeated across many hooks. Also, you might just forget to add the checks in and only realise once a bug surfaces.
One approach to providing an abstraction for this is to create a wrapper around the useEffect
hook that only runs the given the hook if the componet has not aborted, i.e. unmounted. We could call this useAbortableEffect
. Here's an example:
useAbortableEffect((effect,dependencies) => {
// again, we define some state for the mount status
const status = { aborted: false };
React.useEffect(() => {
// pass the status to the hook consumer
// & store the clean up function if the effect returns one
const cleanUpFn = effect(status);
return () => {
// unmounting now, signal this to the hook consumer
status.aborted = true;
// if a clean up function was returned, run it
if (typeof cleanUpFn === 'function') {
cleanUpFn();
}
};
}, [...dependencies]); // eslint-disable-line react-hooks/exhaustive-deps
}
And then in the consumer, the orignal example would turn into this:
useAbortableEffect(
// pass the effect function
status => {
const effect = async () => {
setFetchIsPending(true);
const freshData = await fetchStuffUsingApi()
if (!status.aborted) {
setData(freshData);
setFetchIsPending(false);
}
}
effect();
},
// pass the dependencies
[*dependencies*]
);
Now we have an abstraction for handling aborted effects across our hooks. The solution is not perfect, but it does the job until something better is worked out.