DEV Community

Lacey Glenn
Lacey Glenn

Posted on

Why Your React useEffect Cleanup Function Isn't Running (The Dependency Array Gotcha)

Why Your React useEffect Cleanup Function Isn't Running (The Dependency Array Gotcha)

You add a cleanup function to your useEffect. You expect it to run when the component unmounts, or when a dependency changes before the effect re-runs. You test it. Nothing happens. No console log, no unsubscribe, no cleared interval — just silence, and a bug report from a user seeing duplicate event listeners or a memory leak that grows worse the longer they use your app.

If you've hit this, you're not misunderstanding React's cleanup model in some obvious way. You've almost certainly run into one of a handful of specific dependency array mistakes that are easy to make and genuinely confusing to debug, because the effect looks correct at a glance. Let's go through exactly why this happens and how to actually fix it.

A Quick Refresher on How Cleanup Is Supposed to Work

Before diagnosing the bug, it's worth being precise about what React guarantees:

useEffect(() => {
  const subscription = subscribeToSomething();

  return () => {
    subscription.unsubscribe(); // cleanup function
  };
}, [someValue]);
Enter fullscreen mode Exit fullscreen mode

React runs the cleanup function in exactly two situations:

  1. Right before the effect re-runs, if any value in the dependency array has changed
  2. When the component unmounts

That's the entire contract. If your cleanup function isn't running, it means one of those two conditions is never being met from React's perspective — even if, from your perspective, it obviously should be.

Gotcha #1: The Dependency Array Reference Never Actually Changes

This is, by far, the most common cause. You think a value changed. React disagrees, because it's comparing references, not deep equality.

function ChatRoom({ roomId }) {
  const options = { roomId, serverUrl: 'https://chat.example.com' };

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();

    return () => {
      connection.disconnect(); // you expect this on every re-render
    };
  }, [options]); // 🚨 options is a NEW object every render

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Here's the trap: options is a plain object literal created fresh on every single render. Even if roomId and serverUrl have the exact same values as the previous render, options is a brand-new object in memory — a new reference — every time. React's dependency comparison uses Object.is(), which for objects is a reference check, not a value check.

So what actually happens? The effect thinks its dependency changed on every render, because technically it did — a new object reference counts as "changed" even if every property inside it is identical. This means the cleanup function actually runs constantly, not never — which is its own bug, usually showing up as connections being torn down and recreated far more often than intended, sometimes so fast that intermediate connect/disconnect cycles get lost or racy, making it look like cleanup "isn't running" when it's actually running too often and stepping on itself.

The fix is to depend on the primitive values directly, not the object wrapping them:

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection({
      roomId,
      serverUrl: 'https://chat.example.com',
    });
    connection.connect();

    return () => {
      connection.disconnect();
    };
  }, [roomId]); // ✅ primitive value, stable across renders unless it actually changes

  // ...
}
Enter fullscreen mode Exit fullscreen mode

The same trap applies to arrays and functions passed as dependencies — a new array literal or a new inline function is a new reference every render, regardless of whether its contents are the same.

Gotcha #2: The Effect Never Re-Runs Because You Under-Specified Dependencies

This is the inverse problem, and it's the one that actually produces the symptom you're describing — cleanup silently never firing when you expect it to.

function SearchResults({ query }) {
  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setResults);

    return () => {
      controller.abort(); // expected to cancel stale requests
    };
  }, []); // 🚨 query is used inside but missing from the dependency array
}
Enter fullscreen mode Exit fullscreen mode

Here, query is used inside the effect but isn't listed in the dependency array. React has no way of knowing the effect depends on query, so it never re-runs the effect when query changes — which means the cleanup function never fires either, because from React's point of view, nothing the effect depends on has changed. You'll see the first search request go out, but subsequent keystrokes silently do nothing, or worse, fire new fetches without ever cancelling the previous ones, since the closure inside the effect is permanently locked to the query value from the very first render.

This is exactly the kind of bug the exhaustive-deps ESLint rule from eslint-plugin-react-hooks exists to catch, and if you've disabled that rule anywhere in your codebase — which is common under deadline pressure — this is one of the first places to look.

The fix is straightforward once you see it:

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

  fetch(`/api/search?q=${query}`, { signal: controller.signal })
    .then(res => res.json())
    .then(setResults);

  return () => {
    controller.abort();
  };
}, [query]); // ✅ now the effect re-runs, and cleanup fires correctly on each change
Enter fullscreen mode Exit fullscreen mode

Gotcha #3: Conditional Returns Before the Cleanup Function

This one is sneakier because it's a logic bug wearing a dependency-array costume.

useEffect(() => {
  if (!isEnabled) {
    return; // 🚨 this branch returns undefined, not a cleanup function
  }

  const interval = setInterval(tick, 1000);

  return () => clearInterval(interval);
}, [isEnabled]);
Enter fullscreen mode Exit fullscreen mode

This code is actually fine in isolation — React correctly handles a return; with nothing after it, treating it as "no cleanup needed for this render," and it doesn't error. But the bug shows up when developers copy this pattern and add a second early return, or restructure it during a refactor, and accidentally leave a path where cleanup should exist but the function returns before defining the interval:

useEffect(() => {
  if (!isEnabled) return;

  const interval = setInterval(tick, 1000);
  if (someOtherCondition) {
    return; // 🚨 bug: this skips the cleanup return entirely
  }

  return () => clearInterval(interval);
}, [isEnabled, someOtherCondition]);
Enter fullscreen mode Exit fullscreen mode

If someOtherCondition is true, the interval gets created but the cleanup function that would clear it is never reached — a genuine leak, and one that's easy to miss in review because the code reads correctly at a glance. The general rule: any early return inside an effect body needs to be checked for whether it accidentally bypasses a resource that was already created earlier in that same render of the effect.

Gotcha #4: Stale Closures Inside the Cleanup Function Itself

Sometimes the cleanup function does run, but it's operating on stale data, which looks identical to "not running" from a debugging standpoint if you're checking the wrong thing.

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

  fetchUser(userId).then(user => {
    if (isCurrent) {
      setUser(user);
    }
  });

  return () => {
    isCurrent = false; // this DOES run, just maybe not when you expect
  };
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

This pattern is actually the correct way to guard against race conditions with async effects — it's worth recognizing rather than "fixing," since a naive read of the code sometimes leads developers to assume the cleanup isn't firing when it's working exactly as intended, cancelling the effect of a stale response from a previous userId after a fast switch.

The actual bug version of this looks like:

useEffect(() => {
  const timeoutId = setTimeout(() => {
    console.log(count); // 🚨 stale closure, always logs the count from mount
  }, 5000);

  return () => clearTimeout(timeoutId);
}, []); // empty array means this closure never sees updated `count`
Enter fullscreen mode Exit fullscreen mode

Here the cleanup runs fine, but the callback inside the effect is closed over a stale value of count because the dependency array is empty. This isn't technically a cleanup bug at all — it's a stale closure bug that often gets misdiagnosed as "the cleanup isn't running" because the symptom (stale behavior) looks similar on the surface.

How to Actually Debug This When It Happens to You

  1. Log inside the cleanup function itself, not just the effect body, and check whether it fires on every render (too often — Gotcha #1) or never (too rarely — Gotcha #2).
  2. Turn on exhaustive-deps if it's off. It will flag most of Gotcha #2 and a good chunk of Gotcha #1 automatically, and it's worth the short-term noise of fixing existing violations.
  3. Check whether your dependency is an object, array, or function literal. If it's created inline in the render body, it's a new reference every render — either memoize it with useMemo/useCallback, or depend on the primitive values inside it instead.
  4. Trace every early return inside the effect body to confirm cleanup-relevant resources aren't created after a path that could skip the cleanup return.

The Underlying Lesson

Almost every version of this bug comes down to the same root cause: JavaScript's reference equality doesn't match what a developer intuitively means by "this value is the same as before." React's dependency array is doing exactly what it's told — a shallow reference comparison — and the mismatch between that mechanical behavior and human intuition about sameness is where nearly all of these bugs live. Once you start reading dependency arrays with that lens — "is this a stable reference, or a new one every render?" — most cleanup bugs stop being mysterious and start being obvious the moment you look at the right line.

Top comments (0)