loading...

Cancel your promises when a component unmounts

rodw1995 profile image Robbie op de Weegh ・4 min read

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

Posted on by:

rodw1995 profile

Robbie op de Weegh

@rodw1995

A 24 year old software developer from the Netherlands mainly focused on the JavaScript/Typescript stack. Always curious about the latest developments and enjoying learning new things every day.

Discussion

pic
Editor guide
 

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.

 

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

 

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:

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.

 

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

 

Great article! And it is actually solve my problem.
But does useRef taking a lot of resource? Because when implemented i feel the app is slower than before, especially when move to another page (ex: Other Tabs) before the Async request done.