Stopping Memory Leaks in React Apps Caused by Stale Closures

June 05, 2026 7 min read 4 views
Abstract illustration of a fading memory bubble representing a stale closure leak in a React application, set against a soft gradient background.

Your React component unmounts, but something keeps running in the background β€” a timer fires, a fetch resolves, and then you see it in the console: Can't perform a React state update on an unmounted component. That warning is a symptom. The disease is a stale closure holding on to references your app already threw away.

Stale closures are subtle because they don't crash immediately. They degrade your app over time: extra renders, state updates landing on the wrong component instance, and heap memory that never gets released. This article walks you through exactly why it happens and how to stop it.

What you'll learn

  • Why JavaScript closures cause memory leaks specifically in React hooks
  • How to identify stale closure leaks using browser DevTools and React DevTools
  • The cleanup patterns that prevent leaks in useEffect, event listeners, and timers
  • How to use refs to break the closure trap
  • Common pitfalls and the mistakes that reintroduce leaks after you think you've fixed them

Prerequisites

You should be comfortable with React functional components and hooks (useState, useEffect, useRef). Basic knowledge of how JavaScript closures work will help, but the article explains the relevant mechanics as they come up.

How Closures Work (and Why React Makes Them Dangerous)

A closure is a function that captures variables from its surrounding scope at the time it was created. That's useful in normal code, but in React it creates a specific trap: every render creates a new closure over a snapshot of that render's state and props.

When you pass a callback into setTimeout, setInterval, or an async operation inside useEffect, that callback closes over the state values from the render cycle it was created in. If the component unmounts before the callback runs, the callback still holds a reference to the old state object. JavaScript's garbage collector cannot free that memory because something is still pointing at it.

useEffect(() => {
  setTimeout(() => {
    // This closure captured `count` from the render
    // when this effect ran. Even after unmount, this
    // function still references that render's scope.
    setCount(count + 1);
  }, 3000);
}, []);

The timer above runs three seconds after mount. If the user navigates away in two seconds, the component is gone β€” but the timer callback and everything it captured is not.

Spotting Leaks Before They Become Obvious

The classic warning from React is a good first signal, but it only appears in development mode and React 18 actually removed it for some cases. You need a more reliable detection strategy.

Chrome Memory Profiler

Open DevTools, go to the Memory tab, and take a heap snapshot before and after navigating away from a page you suspect is leaking. Filter the second snapshot to show objects allocated since the first. If you see React fiber nodes, component state objects, or detached DOM elements that should have been collected, you have a leak.

React DevTools Profiler

Record a profile while mounting and unmounting a component repeatedly. Look for components that keep re-rendering after they should be gone, or for component trees that never fully disappear from the flame graph.

Manual Console Check

A quick smoke test: add a console.log inside any async callback. If you see it fire after navigating away, something is still running.

Cleaning Up useEffect: The Right Way

The most common source of stale closure leaks is forgetting β€” or misimplementing β€” the cleanup function returned from useEffect.

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

  async function fetchData() {
    const data = await getUser(userId);
    if (isMounted) {
      setUser(data);
    }
  }

  fetchData();

  return () => {
    isMounted = false;
  };
}, [userId]);

The isMounted flag is a boolean local to this effect's scope. The cleanup function β€” the returned arrow function β€” flips it to false when the component unmounts or when userId changes. The async callback checks the flag before calling setUser, so it bails out cleanly even if the fetch resolves late.

This doesn't free the fetch itself (the network request still completes), but it prevents the callback from writing stale state into a component that's already gone. To actually abort the network request, use AbortController.

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

  async function fetchData() {
    try {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal,
      });
      const data = await response.json();
      setUser(data);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    }
  }

  fetchData();

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

When the component unmounts, controller.abort() is called. The fetch is cancelled at the network level, the AbortError is caught and ignored, and nothing writes to state. The closure and all the objects it referenced can now be collected.

Timers: clearTimeout and clearInterval

Timers are the most common culprit after async fetches. Every setInterval or setTimeout that isn't cleared keeps its callback alive in memory.

useEffect(() => {
  const intervalId = setInterval(() => {
    setTick((prev) => prev + 1);
  }, 1000);

  return () => clearInterval(intervalId);
}, []);

Notice the updater form (prev) => prev + 1 instead of tick + 1. This matters: the updater receives the current state at call time, so the closure doesn't need to capture tick at all. That eliminates one layer of staleness even before you think about cleanup.

Event Listeners and the DOM

When you attach event listeners manually β€” to window, document, or a third-party library's emitter β€” you must remove them in cleanup. React does not track these for you.

useEffect(() => {
  function handleResize() {
    setWidth(window.innerWidth);
  }

  window.addEventListener('resize', handleResize);

  return () => window.removeEventListener('resize', handleResize);
}, []);

The critical detail: the function reference you pass to removeEventListener must be the same object you passed to addEventListener. If you define the function inline in both calls, they're different objects and the listener is never removed. Assign it to a variable first, as shown above.

Breaking the Closure Trap with useRef

Sometimes you need a callback that always sees the latest state, but you don't want to add it to a dependency array (which would re-run the effect every render). The standard solution is storing the callback in a ref.

function useLatestCallback(fn) {
  const ref = useRef(fn);

  useEffect(() => {
    ref.current = fn;
  });

  return useCallback((...args) => ref.current(...args), []);
}

This pattern keeps ref.current pointing at the latest version of the function after every render, without causing the outer effect to re-subscribe. The stable wrapper returned by useCallback can be passed to timers, listeners, or third-party hooks without creating a new closure on every render.

This is also the approach React's own useEffectEvent hook (experimental as of recent React versions) is designed to formalize. You can use the ref pattern today without waiting for that API to stabilize.

Subscriptions and External Libraries

Libraries that use a pub/sub model β€” Redux, Zustand, RxJS observables, WebSocket clients β€” all require explicit unsubscription.

useEffect(() => {
  const subscription = store.subscribe((state) => {
    setLocalState(state.someSlice);
  });

  return () => subscription.unsubscribe();
}, []);

If the library doesn't return an unsubscribe handle, look for a matching method on the client object (socket.off, emitter.removeListener, etc.). There is always a way to detach; you just have to find it in the docs.

Common Pitfalls

Returning the wrong thing from useEffect

The cleanup function must be returned directly from the effect callback, not from an async function. You cannot async the top-level effect callback because async functions return a Promise, and React expects either undefined or a function. Define the async work inside a separate function, call it, and return cleanup separately β€” exactly as shown in the fetch examples above.

Forgetting dependencies cause re-subscription

If your effect depends on a prop or state value but you don't include it in the dependency array, the effect won't re-run when that value changes. You end up with callbacks that permanently close over an outdated value. Run eslint-plugin-react-hooks with the exhaustive-deps rule enabled. It catches these mismatches automatically.

Cleanup running too early

React 18's Strict Mode intentionally mounts, unmounts, and remounts components in development to surface exactly these bugs. If your cleanup runs and your component breaks on the second mount, the cleanup is either too aggressive or your setup isn't idempotent. Fix the setup, not Strict Mode.

Object and array references in dependency arrays

Passing a new object or array literal as a dependency causes the effect to re-run every render, which can create a subscription-and-cleanup loop. Memoize with useMemo or useCallback, or extract the value outside the component if it never changes.

Wrapping Up

Stale closure leaks in React follow a consistent pattern: something async or time-based captures component state, and nothing cancels it when the component is gone. Once you see the pattern, the fixes are straightforward.

Here are five concrete actions to take right now:

  1. Audit every useEffect in your codebase for a missing return cleanup function. Timer, fetch, subscription, listener β€” each one needs cleanup.
  2. Enable the exhaustive-deps ESLint rule if you haven't already. It will surface stale dependency arrays across your entire project automatically.
  3. Replace raw fetch calls with an AbortController-backed wrapper so network requests cancel on unmount.
  4. Switch interval state updates to the updater function form (prev => prev + 1) to remove unnecessary closure dependencies on current state.
  5. Run a Chrome heap snapshot on your most complex page, navigate away, and check for detached component nodes. Fix any you find before they accumulate.

πŸ“€ Share this article

Sign in to save

Comments (0)

No comments yet. Be the first!

Leave a Comment

Sign in to comment with your profile.

πŸ“¬ Weekly Newsletter

Stay ahead of the curve

Get the best programming tutorials, data analytics tips, and tool reviews delivered to your inbox every week.

No spam. Unsubscribe anytime.