DEV Community

Lakshmi Bhargavi Gajula
Lakshmi Bhargavi Gajula

Posted on

Stop Memory Leaks in Their Tracks: React useEffect Cleanup Explained

Stop Memory Leaks in Their Tracks: React useEffect Cleanup Explained

If you've spent any time with React, you've probably seen this warning pop up in your console:

"Warning: Can't perform a React state update on an unmounted component."

Yeah, that one. It's a classic memory leak caused by async operations inside useEffect — and it's way more common than you'd think. Let's break down what's actually happening and how to fix it cleanly.

What's the Problem?

When you kick off an async operation (like a fetch call) inside useEffect, there's a race condition waiting to happen. If the component unmounts before the async call finishes, React will still try to update state on something that no longer exists in the DOM.

Here's a simple example of the problematic pattern:

useEffect(() => {
  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      setData(data); // 💥 component might already be gone!
    });
}, []);
Enter fullscreen mode Exit fullscreen mode

No cleanup = potential memory leak.

The Fix: Return a Cleanup Function

useEffect lets you return a cleanup function that runs when the component unmounts (or before the effect re-runs). The trick with async calls is to use an abort flag or the built-in AbortController.

Option 1: Boolean Flag

Simple and straightforward:

useEffect(() => {
  let isMounted = true;

  fetch('/api/data')
    .then(res => res.json())
    .then(data => {
      if (isMounted) {
        setData(data); // ✅ only update if still mounted
      }
    });

  return () => {
    isMounted = false; // cleanup!
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Option 2: AbortController (the Modern Way)

This one actually cancels the fetch request, which is even better:

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

  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name === 'AbortError') return; // ignore abort errors
      console.error(err);
    });

  return () => {
    controller.abort(); // cancel the request on cleanup
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

This is the preferred approach because it actually stops the network request rather than just ignoring the result.

Quick Rules to Live By

  • Always return a cleanup function from useEffect when dealing with async operations, subscriptions, or timers.
  • Use AbortController for fetch calls when possible.
  • Use a boolean flag as a fallback for non-cancellable async operations.
  • Watch your dependency array — missing deps can cause stale closures and unexpected behavior.

Why Does This Actually Matter?

Beyond silencing console warnings, proper cleanup:

  • Prevents unnecessary re-renders that tank performance
  • Avoids setting state on unmounted components, which can cause subtle bugs
  • Cancels in-flight network requests, saving bandwidth

It's one of those things that seems minor until it causes a weird bug in production at 2am.

Wrapping Up

The useEffect cleanup function is your best friend when working with async code in React. Whether you go with the AbortController approach or a simple boolean flag, the key takeaway is this: always think about what happens when your component disappears before your async work is done.

Get into the habit of writing your cleanup from the start, and you'll save yourself a lot of headaches down the road. Your future self will thank you. 🙌

Top comments (0)