Cleaning up Async Functions in React's useEffect Hook (Unsubscribing)

Functional components in React are most beautiful because of React Hooks. With Hooks, we can change state, perform actions when components are mounted and unmounted, and much more.

While all these are beautiful, there is a little caveat (or maybe not) that is a little bit frustrating when working with useEffect hook.

Before we look at this issue let's do a quick recap on the useEffect hook.

Effect Hook

The useEffect hook allows you to perform actions when components mount and unmount.

useEffect(() => {
  // actions performed when component mounts

  return () => {
    // actions to be performed when component unmounts
  }
}, []);

The callback function of the useEffect function is invoked depending on the second parameter of the useEffect function.

The second parameter is an array of dependencies. You list your dependencies there.

So whenever there is an update on any of the dependencies, the callback function will be called.

useEffect(() => {
  if (loading) {
    setUsername('Stranger');
  }
}, [loading]);

If the array of dependencies is empty like in our first example, React will only invoke the function once and that is when the component mounts.

But you may wonder, "what about when it unmounts, doesn't React call the function too"?.

Uhmmm no. The returned function is a closure and you really do not need to call the parent function (the callback function now) when you have access to the scope of the parent function right in the function you need (the returned function now).

If this isn't clear to you, just take out 7 mins of your time to take a look at an article on JavaScript closures I wrote.

So now we have gone through the basics as a recap, let's take a look at the issue with async functions.

Async functions in React

There is no doubt that you may have once used an async function inside the useEffect hook. If you haven't you are eventually going to do so soon.

But there is a warning from React that appears most times when we unmount and mount a component when we have an async function in the useEffect hook. This is the warning

If you can't see the image, here is the 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 instruction is pretty clear and straightforward, "cancel all subscriptions and asynchronous tasks in a useEffect cleanup function". Alright, I hear you React! But how do I do this?

It's simple. Very simple. The reason React threw that warning was because I used a setState inside the async function.

That's not a crime. But React will try to update that state even when the component is unmounted, and that's kind of a crime (a leakage crime).

This is the code that led to the warning above

useEffect(() => {
  setTimeout(() => {
    setUsername('hello world');
  }, 4000);
}, []);

How do we fix this? We simply tell React to try to update any state in our async function only when we are mounted.

So we thus have

useEffect(() => {
  let mounted = true;
  setTimeout(() => {
    if (mounted) {
      setUsername('hello world');
    }
  }, 4000);
}, []);

Ok, now we have progressed a little. Right now we are only telling React to perform an update if mounted (you can call it subscribed or whatever) is true.

But the mounted variable will always be true, and thus doesn't prevent the warning or app leakage. So how and when do we make it false?

When the component unmounts we can and should make it false. So we now have

useEffect(() => {
  let mounted = true;
  setTimeout(() => {
    if (mounted) {
      setUsername('hello world');
    }
  }, 4000);

  return () => mounted = false;
}, []);

So when the component unmounts the mounted variable changes to false and thus the setUsername function will not be updated when the component is unmounted.

We can tell when the component mounts and unmounts because of the first code we saw i.e

useEffect(() => {
  // actions performed when component mounts

  return () => {
    // actions to be performed when component unmounts
  }
}, []);

This is how you unsubscribe from async functions, you can do this in different ways like

useEffect(() => {
  let t = setTimeout(() => {
    setUsername('hello world');
  }, 4000);

  return () => clearTimeout(t);
}, []);

Here is an example with an async function with the fetch API.

useEffect(() => {
  let mounted = true;
  (async () => {
    const res = await fetch('example.com');
    if (mounted) {
      // only try to update if we are subscribed (or mounted)
      setUsername(res.username);
    }
  })();

  return () => mounted = false; // cleanup function
}, []);

Update: As suggested by @joeattardi in the comments, we can use the AbortController interface for aborting the Fetch requests rather than just preventing updates when unmounted.

Here is the refactored code of the last example.

useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  (async () => {
    const res = await fetch('example.com', {
      signal,
    });
    setUsername(res.username));
  })();

  return () => controller.abort();
}, []);

Now React will not try to update the setUsername function because the request has been aborted. Just like the refactored setTimeout example.

Conclusion

When I was still new in React, I used to struggle with this warning a lot. But this turned things around.

If you are wondering, "why does it only happen with async functions or tasks"? Well, that's because of the JavaScript event loop. If you don't know what that means, then check out this YouTube Video by Philip Roberts.

Thanks for reading. I hope to see you next time. Please kindly like and follow me on Twitter @elijahtrillionz to stay connected.

30