DEV Community

Cover image for How to fix the React memory leak warning
Jonathan Experton
Jonathan Experton

Posted on • Updated on • Originally published at jexperton.dev

How to fix the React memory leak warning

If you've ever worked with React function components and the useEffect hook, it's almost impossible that you've never faced this warning:

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

This is the warning I'm referring to as the React memory leak warning because it is very easy to trigger and hard to get rid of if you don't understand what's happening.

Explaining the warning

There are 4 important concepts here:

  • Can't perform a React state update
  • on an unmounted component.
  • To fix, cancel all subscriptions and asynchronous tasks
  • in a useEffect cleanup function.

I won't explain what a memory leak is, instead I'll encourage you to read what is my go-to article about memory management in Javascript.

What is a state update?

Given the following state initialization:

const [isVisible, setIsVisible] = useState(true);
Enter fullscreen mode Exit fullscreen mode

A state update would be:

setIsVisible(false);
Enter fullscreen mode Exit fullscreen mode

What is an unmounted component?

A component is unmounted when it is removed from the DOM. It is the final step of a React component's life cycle.

What are subscriptions and asynchronous tasks?

Asynchronous tasks are callbacks sent to the queue of callbacks of the event loop. They are asynchronous because they won't be executed until some conditions are met.

Any mechanism that can add a callback to the queue of callbacks, thereby deferring its execution until the fulfillment of a condition, can be considered as a subscription:

I've skipped setImmediate since it's not a web standard, and I'm simplifying things by referring to a unique queue of callbacks when there's in fact multiple queues with different levels of priority.

Case 1 - Asynchronous task in a Promise handler

someAsyncFunction().then(() => {
  // Here is the asynchronous task.
});
Enter fullscreen mode Exit fullscreen mode

someAsyncFunction() returns a Promise we can subscribe to by calling the then() method with a callback function as the task to execute when someAsyncFunction() resolves.

Case 2 - Asynchronous task in a setTimeout handler

setTimeout(() => {
  // Here is the asynchronous task.
});
Enter fullscreen mode Exit fullscreen mode

setTimeout is usually called with a delay as a second argument, but when left empty, the event handler will be executed as soon as the event loop starts to process the queue of callbacks, but it is still asynchronous and has a significant chance to be executed after the component has been unmounted.

Case 3 - Asynchronous task in an event handler

Dimensions.addEventListener('change', ({ screen }) => {
  // Here is the asynchronous task.
});
Enter fullscreen mode Exit fullscreen mode

Subscribing to an event is done by adding an event listener and passing a callback function to the listener.

Until the event listener is removed or the event emitter is destroyed, the callback function will be added to the queue of callbacks on every event occurrence.

Asynchronous tasks are side effects

In React functional components any side effects such as data fetching or event handling should be done inside a useEffect:

useEffect(() => {
  someAsyncFunction().then(() => {
    // Here is an asynchronous task.
  });

  Dimensions.addEventListener('change', ({ screen }) => {
    // There is another asynchronous task.
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

What is a useEffect cleanup function?

Every effect may return a function that cleans up after it. This function is called when the component is unmounted.

useEffect(() => {
  return () => {
    // This is the cleanup function
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

What is wrong?

React is telling us to stop trying to update the state of a component that has been deleted.

Case 1 - Asynchronous task in a Promise handler

useEffect(() => {
  someAsyncFunction().then(() => {
    setIsVisible(false);
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

Because we've subscribed to a Promise, there's a pending callback, waiting for the Promise to settle, regardless of whether it has been fulfilled or rejected.

If the React component is unmounted before the Promise completion, the pending callback stays in the callback queue anyway.

And once the Promise has settled, it will try to update the state of a component that doesn't exist anymore.

Case 2 - Asynchronous task in a setTimeout handler

useEffect(() => {
  setTimeout(() => {
    setIsVisible(false);
  }, 5000);
}, []);
Enter fullscreen mode Exit fullscreen mode

This code is close to the previous case except that the condition for the callback to be executed is to wait 5000ms.

If the React component is unmounted before this amount of time, it will also try to update the state of a component that doesn't exist anymore.

Case 3 - Asynchronous task in an event handler

useEffect(() => {
  Dimensions.addEventListener('change', ({ screen }) => {
    setDimensions(screen);
  });
}, []);
Enter fullscreen mode Exit fullscreen mode

Attaching handlers to events is different from the previous cases because events can occur multiple times and therefore can trigger the same callback multiple times.

If the event emitter we've bound an event handler is not destroyed when the React component is unmounted, it still exists and will be executed on every event occurrence.

In the above example, the event handler is bound to a global variable Dimensions, the event emitter, which exists outside of the scope of the component.

Therefore, the event handler is not unbound or garbage collected when the component is unmounted, and the event emitter might trigger the callback in the future even though the component doesn't exist anymore.

Fixing the problem

Case 1 - Asynchronous task in a Promise handler

Since it is not possible to cancel a Promise the solution is to prevent the setIsVisible function to be called if the component has been unmounted.

const [isVisible, setIsVisible] = useState(true);

useEffect(() => {
  let cancel = false;

  someAsyncFunction().then(() => {
    if (cancel) return;
    setIsVisible(false);
  });

  return () => { 
    cancel = true;
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

By leveraging lexical scoping, we can share a variable between the callback function and the cleanup function.

We use the cleanup function to modify the cancel variable and trigger an early return in the callback function to prevent the state update.

Case 2 - Asynchronous task in a setTimeout handler

To remove a callback bound to a timer, remove the timer:

useEffect(() => {
  const timer = setTimeout(() => {
    setIsVisible(false);
  });
  return () => {
    clearTimeout(timer);
  }
}, []);
Enter fullscreen mode Exit fullscreen mode

Case 3 - Asynchronous task in an event handler

To cancel a subscription to an event, remove the event handler:

const onChange = ({ screen }) => {
  setDimensions(screen);
};

useEffect(() => {
  Dimensions.addEventListener('change', onChange);
  return () => {
    Dimensions.removeEventListener('change', onChange);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Conclusion

  • Global variables are never garbage collected so don't forget to remove event handlers manually if the event emitter is stored in a global variable.

  • Remove any event handlers bound to event emitters that might not be removed when a component is unmounted.

  • Promises cannot be cancelled but you can use lexical scoping to change the behavior of the callback from the useEffect cleanup function by triggering an early return or short-circuiting the state update.

  • Try to avoid timers, if you can't, be sure to always cancel them with clearTimeout or clearInterval.

Photo by Aarón Blanco Tejedor on Unsplash

Top comments (14)

Collapse
 
supernova233 profile image
Korawit S

thank you for explain, this post is useful !

Collapse
 
muinmundzir profile image
Muhammad Mu'in Mundzir

Nice, this post save me. Thanks!

Collapse
 
aalphaindia profile image
Pawan Pawar

keep sharing!!

Collapse
 
hrafaelalves profile image
Hugo Rafael Alves

This post was very useful, thanks!

Collapse
 
thedeveshpareek profile image
Devesh pareek

nice thanks for the details

Collapse
 
zaselalk profile image
Asela

Nice Explanation, Thank you

Collapse
 
uwenayoallain profile image
Uwenayo Alain Pacifique

nice post, thanks

Collapse
 
rooneyhoi profile image
Dax Truong

thanks for the details summary

Collapse
 
dk00 profile image
Derek Shih

Hi Jonathan,

The warning is removed in React 18.

Collapse
 
gauravsinghbisht profile image
Gaurav Singh Bisht

Very detail information about the issue. Thanks

Collapse
 
stanislusa profile image
Stanislaus

Nice!!

Collapse
 
tasin5541 profile image
Miftaul Mannan

Is the promise handler situation still relevant according to the warning removal from react v18 and the discussion in the thread?

github.com/facebook/react/pull/22114

Collapse
 
laveshgarg80 profile image
Lavesh Garg

Amazing Explanation, This post is super helpful

Collapse
 
jeromechua profile image
Jerome Chua

Nice, super detailed 👍