Dec 27, 2021

React & useEffect cleanups

React 18 Automatic Batching

Before jumping into what is a useEffect cleanup and when we need to apply it, take a look at the code below. Do you notice anything that is off?

import { useEffect, useState } from "react";

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log("Increasing count...");
      setCount((c) => c + 1);
    }, 1000);
  }, []);

  return <h4>Count: {count}</h4>;
};

export default function App() {
  const [showCounter, setShowCounter] = useState(false);

  return (
    <div>
      <button onClick={() => setShowCounter((sc) => !sc)}>
        Show/Hide Counter
      </button>
      {showCounter && <Counter />}
    </div>
  );
}

Now take a look at the demo and the console below:

Demo

So what is happening here?

  1. We click the button to show the Counter
  2. The Counter component is mounted and the interval starts
  3. We click the button again to hide the Counter
  4. The Counter component unmounts but the interval never stops, it keeps running

So despite the fact that the Counter component unmounts after we click the button for the second time, the interval created inside the useEffect of Counter does not stop and it's running forever causing this error in the console:

index.js:27 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.

➡️ Demo is available at: https://codesandbox.io/s/react-setinterval-memory-leak-4j60q

What is a useEffect cleanup?

In most cases when we refer to a "useEffect cleanup" we mean that we need to write some code that will guarantee that will eliminate any potential memory leaks and that our app will remain bug free.

A potential memory leak, as we saw at our counter demo before, could be that we didn't stop an interval (clear setInterval) when the component which created that interval unmounted, so now our interval keeps running forever! Or it could be that we didn't clear a subscription we created and now we've got a listener that doesn't ever get deactivated!

The situations above can very easily introduce major bugs to our app and despite the fact cleanups from useEffect are not always required, we need to acquire that as a best practice and whenever we write a useEffect we must ask ourselves: "If the component unmounts, is unwanted code still running?" or "Do I need a cleanup function for this effect?".

How to cleanup?

Writing useEffect cleanup functions is pretty easy and straightforward. We just return a function from our useEffect as seen below:

useEffect(()=> {
  // our effect here...

  // we return the cleanup fn
  return () => {
    // our cleanup code here..
  }
}, []);

When do cleanups run?

React performs the cleanup when the component unmounts. However, due to the fact that effects may run for many renders (and not just once), React also cleans up effects from the previous render before running the effects next time. Don't worry if it's hard to grasp this, we'll better understand it with the examples below.

Cleanup Examples

Let's fix our counter demo from before and introduce a cleanup function that will run when Counter component unmounts and will stop the interval:

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    let interval;
    interval = setInterval(() => {
      console.log("Increasing count...");
      setCount((c) => c + 1);
    }, 1000);

    // our cleanup will run when the component unmounts
    return () => {
      clearInterval(interval);
    }
  }, []);

  return <h4>Count: {count}</h4>;
};

So generally speaking a subscription on a useEffect can be created and destroyed somewhat like this:

const Subscription = () => {

  useEffect(() => {
    createSubscription();

    // our cleanup
    return () => {
      destroySubscription();
    }
  }, []);

  return <div>...</div>
};

React also cleans up effects from the previous render before running the effects next time.

Based on the sentence above, let's take a look at the example below:

const FriendStatus = (props) => {
  const { friendId } = props;

  useEffect(() => {
    // ...
    subscribeToFriendStatus(friendId, handleStatusChange);
  }, [friendId]);

  return <div>...</div>
};

The FriendStatus component above takes a friendId as a prop and subscribes to the friend's status with that friendId, which means that whenever the status of a friend changes we need to execute a function that for demo purposes we named it as handleStatusChange.

The useEffect will run once on mount and then whenever friendId changes (as we have passed friendId to our dependencies list of useEffect).

So imagine the scenario and flow below:

  1. Run first effect: Mount with friendId: 1 -> subscribeToFriendStatus(1, handleStatusChange)

(friendId changes)

  1. Run next effect: Update with friendId: 2 -> subscribeToFriendStatus(2, handleStatusChange)

(friendId changes)

  1. Run next effect: Update with friendId: 3 -> subscribeToFriendStatus(3, handleStatusChange)

The problem is that we never unsubscribe from a friendId before subscribing to the newly updated friendId and that means we end up having all these open subscriptions for all these friendIds!

By adding the cleanup function, as seen below, React will execute it before running effects for the next render (and of course eventually on unmount).

const FriendStatus = (props) => {
  const {friendId} = props;

  useEffect(() => {
    // ...
    subscribeToFriendStatus(friendId, handleStatusChange);

    return () => {
      unSubscribeToFriendStatus(friendId, handleStatusChange);
    }
  }, [friendId]);

  return <div>...</div>
};

So the corrected flow now becomes:

  1. Run first effect: Mount with friendId: 1 -> subscribeToFriendStatus(1, handleStatusChange)

(friendId changes)

  1. Clean up previous effect: Unsubscribe from friendId: 1 -> unSubscribeToFriendStatus(1, handleStatusChange)
  2. Run next effect: Update with friendId: 2 -> subscribeToFriendStatus(2, handleStatusChange)

(friendId changes)

  1. Clean up previous effect: Unsubscribe from friendId: 2 -> unSubscribeToFriendStatus(2, handleStatusChange)
  2. Run next effect: Update with friendId: 3 -> subscribeToFriendStatus(3, handleStatusChange)

(component unmounts)

  1. Clean up previous effect: Unsubscribe from friendId: 3 -> unSubscribeToFriendStatus(3, handleStatusChange)

The End

I hope you liked this article and found it informative. Catch you later! 😀