In basically all React applications you will need to perform some async operations in your components. A common example would be to fetch the authenticated user on mount:
import useDidMount from '@rooks/use-did-mount';
import { useState } from 'react';
export default () => {
const [user, setUser] = useState();
// Fetch the authenticated user on mount
useDidMount(() => {
fetchAuthenticatedUser().then((user) => {
setUser(user);
});
});
// Rest of the component...
};
At first glance, this all seems fairly valid, but it can cause the following 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.
So what does this mean? It is quite logical what happened if this error occurs in the above example, in that case:
- The component unmounted before the "fetchAuthenticatedUser" promise was resolved.
What means that if the promise does resolve:
- The setUser function is called on an unmounted component.
This is not allowed and to resolve this issue:
- The promise has to be canceled when the component unmounts.
So how are we going to fix this?
Component still mounted?
First we need a way to check if a component is still mounted. We can do so
by making use of the cleanup function in a useEffect hook.
Every effect may return a function that cleans up after it.
So with the help of this cleanup function we can keep track of the mounted state and we can fix the potential error in the example code:
import useDidMount from '@rooks/use-did-mount';
import { useState } from 'react';
export default () => {
// Keep track of the mounted state
const mountedRef = useRef<boolean>(false);
const [user, setUser] = useState();
// Fetch the authenticated user on mount
useDidMount(() => {
// Is mounted now
mountedRef.current = true;
fetchAuthenticatedUser().then((user) => {
// Before calling "setUser", check if the component is still mounted
if (mountedRef.current) {
setUser(user);
}
});
// Also in "useDidMount" we can use this cleanup function because it is just a wrapper around "useEffect"
return () => {
// Called before unmount by React
mountedRef.current = false;
};
});
// Rest of the component...
};
This will fix the potential error already. However, we probably need to do this in many components and therefore we can make it a little bit cleaner and more DRY with a custom hook called useMountedState:
useMountedState
We basically want to extract the "mountedRef" part from above code in a custom hook. So we can then return a function which returns the current mounted state of the component:
import { useCallback, useEffect, useRef } from 'react';
export default (): () => boolean => {
const mountedRef = useRef<boolean>(false);
// Basically the same as "useDidMount" because it has no dependencies
useEffect(() => {
mountedRef.current = true;
return () => {
// The cleanup function of useEffect is called by React on unmount
mountedRef.current = false;
};
}, []);
return useCallback(() => mountedRef.current, []);
};
Next, we can use this custom hook to make the fix a little bit cleaner:
import useDidMount from '@rooks/use-did-mount';
import { useState } from 'react';
import useMountedState from './useMountedState';
export default () => {
const isMounted = useMountedState();
const [user, setUser] = useState();
// Fetch the authenticated user on mount
useDidMount(() => {
fetchAuthenticatedUser().then((user) => {
// Before calling "setUser", check if the component is still mounted
if (isMounted()) {
setUser(user);
}
});
});
// Rest of the component...
};
Already a little better, right? But we can do it even better with another custom hook, which will use the useMountedState hook internally. We will call this one useCancelablePromise:
useCancelablePromise
The purpose of this hook is to create a wrapper function that we can use in our components around promises. So the hook needs to give us:
- A function which accepts a promise and returns a promise
- Where the returned promise resolves or rejects with the result of the accepted/wrapped promise
- Only when the component is still mounted
May sound a bit tricky, but it's pretty simple:
import { useCallback } from 'react';
import useMountedState from './useMountedState';
export default () => {
// Use our just created custom hook to keep track of the mounted state
const isMounted = useMountedState();
// Create our function that accepts a promise
// Note the second parameter is a callback for onCancel. You might need this in rare cases
return useCallback(<T>(promise: Promise<T>, onCancel?: () => void) =>
// Wrap the given promise in a new promise
new Promise<T>((resolve, reject) => {
promise
.then((result) => {
// Only resolve the returned promise if mounted
if (isMounted()) {
// Resolve with the result of the wrapped promise
resolve(result);
}
})
.catch((error) => {
// Only reject the returned promise if mounted
if (isMounted()) {
// Reject with the error of the wrapped promise
reject(error);
}
})
.finally(() => {
// Call the onCancel callback if not mounted
if (!isMounted() && onCancel) {
onCancel();
}
});
}),
[isMounted]);
};
Now we can change our example code for the last time:
import useDidMount from '@rooks/use-did-mount';
import { useState } from 'react';
import useCancelablePromise from './useCancelablePromise';
export default () => {
const makeCancelable = useCancelablePromise();
const [user, setUser] = useState();
// Fetch the authenticated user on mount
useDidMount(() => {
makeCancelable(fetchAuthenticatedUser()).then((user) => {
setUser(user);
});
});
// Rest of the component...
};
Cleaner and still safe! Because the promise returned from makeCancelable is only resolved (or rejected) if the component is mounted 🎉
Library
The source code of both custom hooks created in this article can be found on my Github:
useMountedState
useCancelablePromise
And they are also both published as a npm package. So you can use them directly in your React (native) project by just adding them:
yarn add @rodw95/use-mounted-state
yarn add @rodw95/use-cancelable-promise
So make your promises cancelable and safe! Happy coding 😀
Cheers
Top comments (9)
Hello Robbie, neat trick. Does the promise that is never resolved or rejected get cleaned up (garbage collected), or does it stick around and leak memory?
Even if we're done with the component, will the engine be hanging on to each
then
handler waiting indefinitely to callsetUser
, which never happens?One way to test this would be to re-render a component that uses this tool many times by incrementing a
key
prop on it like 100,000 times, and seeing if memory growth is garbage collected in Chrome Devtool's Performance tab.What I do to ensure my Promises are cleaned up (so that dependent
then
orcatch
handlers aren't waiting indefinitely) is to reject them with a special error that I look out for in acatch
, an error likeclass Cancellation extends Error
, and when I reject the promise with that error the dependent then/catch handlers know the promise is settled (resolved or rejected) and in the case they receive a Cancellation rejection then they don't handle that error (or do something likeonCancel
), and otherwise handle other types of errors like regular errors.Hi Joe, thank you for your comment and good point! I have not tested it but did some more research and I think you're probably right about the memory leak. There is also a discussion in a React issue on Github relevant to this. Maybe this can be a solution?
Hey, sorry for the late reply! That TrashablePromise implementation can still leak the promises if they never settle (same with the ideas mentioned in that GitHub issue). The only way to cancel a promise, is to reject it. But if we receive a promise and it is out of our control as to whether we can make it settle, then that TrashablePromise idea (or CancelablePromise from that GitHub issue) is the next best option, and in that case we can only hope the externally provided Promise will sometime settle.
Regarding the Fetch API, it has an Abort API which we can take advantage of to cancel the network operation, and we should make sure to reject any promises that we may have handed to any other code. It is expected that any other code handling promises should handle rejections (with
.catch
ortry-catch
). I think this is often overlooked. I have a lint rule in my project that throws an error on any code that doesn't handle promise rejection.The downstream code can check the error to see if it is a special type of error that signifies cancellation. This is all up to the promise author to implement and document as part of their API documentation.
It would be neat if there was something higher level, but even if there was, ultimately the end developer would still need to explicitly stop async operations. I think this isn't as well-known as it should be, at least it seems to be an edge case that devs forget to handle until it becomes a problem (we are human after all 😃).
Hi! I'm fairly new to react development so sorry if the question is amateur. In these cases what I usually do is that I just get the user in maybe a useEffect hook, and just handle the mount case (when there's no user set yet) with a conditional. Am I doing it wrong, or just inelegant?
Hi Richard, I'm not sure what you mean. What I do in this example is the same as fetching the user in a useEffect hook:
So, also in this example you would probably handle the mount case with a conditional for your component rendering.
Ah, I see, then I just misunderstood useDidMount, thanks for the answer!
thank you for this article, it is 2023 now and as I am starting to learn about this problem, I wonder if this continue to be the best way to do, or if there had been recent changes in react that suggest a different approach?
isMounted()
is actually deprecated and is regarded as an anti-pattern by React: reactjs.org/blog/2015/12/16/ismoun...What if I don't want to cancel the promise when the component unmounts? How do I do this without a memory leak?