DEV Community

Robbie
Robbie

Posted on

Cancel your promises when a component unmounts

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...
};

Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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, []);
};
Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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]);
};
Enter fullscreen mode Exit fullscreen mode

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...
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

So make your promises cancelable and safe! Happy coding 😀

Cheers

Top comments (9)

Collapse
 
trusktr profile image
Joe Pea

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 call setUser, 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 or catch handlers aren't waiting indefinitely) is to reject them with a special error that I look out for in a catch, an error like class 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 like onCancel), and otherwise handle other types of errors like regular errors.

Collapse
 
rodw1995 profile image
Robbie • Edited

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?

Collapse
 
trusktr profile image
Joe Pea

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 or try-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 😃).

Collapse
 
pencillr profile image
Richard Lenkovits

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?

Collapse
 
rodw1995 profile image
Robbie

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:

useDidMount is just a custom hook that uses the useEffect hook. And because it uses the useEffect hook with no dependencies it can ensure that the callback is only executed ones i.e. after mount.

So, also in this example you would probably handle the mount case with a conditional for your component rendering.

Collapse
 
pencillr profile image
Richard Lenkovits

Ah, I see, then I just misunderstood useDidMount, thanks for the answer!

Collapse
 
cesarpachon profile image
cesar pachon

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?

Collapse
 
mclean25 profile image
Alex McLean • Edited

isMounted() is actually deprecated and is regarded as an anti-pattern by React: reactjs.org/blog/2015/12/16/ismoun...

Collapse
 
varuns924 profile image
Varun S

What if I don't want to cancel the promise when the component unmounts? How do I do this without a memory leak?