Memory leak React

Memory leak React

Today we'll dive deeper into memory leaks in React, exploring the concepts, prevention techniques, and debugging process.

What Is a Memory Leak? An Analogy

Imagine that each component in your application is a tenant in a building (your program's memory). When a tenant (component) moves out (is unmounted), they must return the keys and cancel all the services they signed up for (subscriptions, timers, etc.).

A memory leak happens when the tenant moves out but forgets to cancel a service, like a magazine subscription. The magazine (the resource) continues to be delivered to an empty apartment, and the building's management (JavaScript) cannot rent that space to a new tenant because there is still a pending item associated with it. Over time, several apartments get stuck in this state, and the building runs out of useful space, making everything slower and more inefficient.

In React, the tenant is the component, and the services are asynchronous operations like API calls, setInterval, or addEventListener.

A Deeper Look at Causes and Solutions

1. Asynchronous Operations and Component State

The core of the problem lies in the interaction between the component's lifecycle and the asynchronous nature of JavaScript.

  • The Problem: A component initiates an operation that will take time (e.g., fetch). While it waits, the user navigates to another page, and React unmounts the component. When the fetch response finally arrives, the code attempts to call setState on a component that no longer exists on the screen.
  • Why is this a "leak"? The .then() callback function (which calls setState) still holds a reference to the component in its scope (a closure). Because this reference still exists, the JavaScript Garbage Collector cannot free the memory allocated for the unmounted component. It remains "stuck" in memory, which is the definition of a leak.

Advanced Solution: Custom Hook for Mount State

An elegant approach is to create a custom hook that tracks whether the component is still mounted. This simplifies the logic inside your components.

// hooks/useIsMounted.js
import { useRef, useEffect } from 'react';

export function useIsMounted() {
  const isMountedRef = useRef(true);

  useEffect(() => {
    // The useEffect cleanup function is the perfect place to update the ref
    return () => {
      isMountedRef.current = false;
    };
  }, []); // The empty array ensures this runs only on mount and unmount

  return isMountedRef;
}

// How to use it in your component:
import { useState, useEffect } from 'react';
import { useIsMounted } from './hooks/useIsMounted';

function MyComponent() {
  const [data, setData] = useState(null);
  const isMounted = useIsMounted(); // Our custom hook

  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(result => {
        // ONLY UPDATE STATE IF THE COMPONENT IS STILL MOUNTED
        if (isMounted.current) {
          setData(result);
        }
      });
  }, []); // Dependencies for your fetch
}        

This approach is more declarative and cleaner than AbortController for simple cases, although AbortController is more robust because it actually cancels the network request, saving bandwidth.

2. Event Handlers on Global Objects (window, document)

Adding events to objects that exist outside of React's lifecycle (like window) is a common source of leaks.

  • The Problem: You add a window.addEventListener('resize', handleResize) in useEffect. If you don't remove this listener, it will continue to exist and hold a reference to the handleResize function. If handleResize uses any state or props from your component, the entire component will be kept in memory.
  • Important Detail: The function you pass to removeEventListener must be the exact same instance as the function you passed to addEventListener.

Example of what NOT to do:

useEffect(() => {
  // WRONG: A new function is created on every render
  window.addEventListener('scroll', () => console.log(window.scrollY));

  return () => {
    // THIS DOESN'T WORK! It's a different function instance.
    window.removeEventListener('scroll', () => console.log(window.scrollY));
  };
}, []);        

Correct Example:

useEffect(() => {
  // CORRECT: The function is defined once, and its reference is preserved.
  const handleScroll = () => console.log(window.scrollY);

  window.addEventListener('scroll', handleScroll);

  return () => {
    window.removeEventListener('scroll', handleScroll); // Removes the same function
  };
}, []);        

How to Debug and Find Memory Leaks in Practice

Using the Chrome DevTools is the most effective way to confirm a leak.

Detailed Step-by-Step Guide:

  1. Open Developer Tools (F12) and go to the Memory tab.
  2. Select the "Heap snapshot" Profile and click the "Take snapshot" button (the circle icon). This takes a "picture" of everything in memory at that moment.
  3. Perform the Suspected Action: Navigate to the page containing the component you suspect is leaking. Then, navigate away from it. Repeat this a few times. The goal is to mount and unmount the component multiple times.
  4. Take a Second Snapshot: Click "Take snapshot" again.
  5. Compare the Snapshots:In the view for the second snapshot, change the filter from "Summary" to "Comparison". This will show only the objects that were allocated between the first and second snapshots.Search for "(detached)" in the search bar. A "Detached HTMLDivElement" (or similar) refers to a DOM element that has been removed from the page but is still in memory. This is a strong sign of a memory leak.Click on one of these "detached" objects. In the "Retainers" panel below, you can see the chain of references that is preventing that object from being garbage collected. This will often lead you directly to the cause of the problem, such as a fetch callback or an active event listener.

Another Useful Tool: console.log in the Cleanup Function

A simple but effective technique is to place a console.log in your useEffect cleanup function to ensure it's being called when the component unmounts.

useEffect(() => {
  // ... your effect logic

  return () => {
    console.log('Component unmounted, cleanup executed!');
  };
}, []);        

Don't forget to delete this console.log before uploading the code to production

If you navigate away from the page and don't see this message in the console, the cleanup function is not being triggered as expected, which can be a clue.

In summary, the key to preventing memory leaks in React is discipline: for every resource you open, subscribe to, or schedule in a useEffect, you must have corresponding logic to close, unsubscribe, or clear it in the return (cleanup) function of that same useEffect.


Eyji K.

Software Engineer | Python, Django, AWS, RAG

2w

Thanks for sharing, Fernando

Like
Reply
Vicente Mattos

FullStack Software Engineer | React.js | Next.js | Typescript | Zustand | Radix | Material UI | Tailwind CSS | SCSS | Node.js | Django

3w

Great post!

Guilherme Luiz Maia Pinto

Back End Engineer | Software Engineer | TypeScript | NodeJS | ReactJS | AWS | MERN | GraphQL | Jenkins | Docker

3w

Thanks for sharing 🚀

Like
Reply
Fernando Miyahira

Software Engineer | Mobile Developer | Flutter | Dart

3w

Thanks for sharing!

Like
Reply
Guilherme Menezes

Software engineer | Frontend Developer | React.Js

4w

Great content!

Like
Reply

To view or add a comment, sign in

Others also viewed

Explore topics