Articles

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

Struggling with React useEffect cleanup functions that refuse to fire? We break down the common dependency array mistakes causing memory leaks and duplicate event listeners in your apps.

Written by:
APin

Senior Technology Analyst • Verified Expert

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

Struggling with React useEffect cleanup functions that refuse to fire? We break down the common dependency array mistakes causing memory leaks and duplicate event listeners in your apps.

The Contract: When Should Cleanup Actually Run?

React’s useEffect contract is deliberately narrow: the cleanup function is invoked only in two, well‑defined moments. First, just before the effect is scheduled to run again because at least one entry in its dependency array has changed (as determined by Object.is reference comparison). Second, when the component that owns the effect is removed from the UI tree. No other lifecycle hook triggers cleanup, and React makes no guarantees about “later” or “asynchronously” running it.

Understanding this contract prevents the most common memory‑leak bugs. The following checklist helps engineers verify that their effects respect the two‑point guarantee.

  • Dependency stability: Ensure every value listed in the array changes only when the underlying data truly changes. Primitive values (strings, numbers, booleans) are safe; objects, arrays, and functions are compared by reference, so an inline literal creates a new reference on every render and forces the effect to re‑run each time.
  • Complete dependency list: Any variable used inside the effect body must appear in the array. Omitting a variable (e.g., a query string) makes React believe the effect does not depend on it, so the effect never re‑runs and the cleanup never fires.
  • Avoid early returns that skip cleanup: If a conditional return occurs after a resource is allocated (e.g., setInterval), the cleanup function may never be returned, leaving the resource dangling.

Practical example respecting the contract:

function SearchResults({ query }) {
  useEffect(() => {
    const controller = new AbortController();
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(setResults);

    // Cleanup runs before the next fetch or on unmount
    return () => controller.abort();
  }, [query]); // ← primitive, changes only when the user types a new query
}

Contrast this with a broken pattern:

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

  return () => conn.disconnect();
}, [options]); // options is a new object each render → cleanup runs on every render

By memoizing options or by listing the primitive roomId directly, the effect runs only when the actual data changes, and the cleanup adheres to the two‑point guarantee.

When debugging, log inside the cleanup itself and verify that logs appear exactly when a dependency changes or when the component unmounts. Enabling the exhaustive-deps rule from eslint-plugin-react-hooks automates most of the checklist above, ensuring the contract is honored across the codebase.

Gotcha #1: The Reference Equality Trap

In JavaScript, React’s dependency comparison mechanism relies on Object.is(). For non-primitive types—specifically objects, arrays, and function literals—this comparison is based on reference equality rather than structural or "deep" equality. When these structures are defined within the component's render body, they are re-allocated in memory every time the component re-renders, resulting in a unique memory reference each cycle.

If such a literal is included in a useEffect dependency array, React perceives the dependency as having "changed" on every render. This forces the useEffect to execute its setup logic and its associated cleanup function repeatedly. This creates significant performance degradation and, in cases involving external API connections or DOM event listeners, can introduce race conditions where the application rapidly toggles between connection states.

Common Reference Equality Traps

  • Object Literals: Defining const options = { id: 1 } inside the component body creates a new reference on every render.
  • Array Literals: Passing [value1, value2] directly to the dependency array.
  • Inline Functions: Defining event handlers or callback functions directly within the render flow.

Recommended Strategies for Mitigation

To ensure stable references and predictable effect execution, engineers should adopt the following patterns:

  • Depend on Primitives: Instead of passing an entire object as a dependency, pass the specific primitive values (e.g., id) used within the effect. React will only trigger the effect when those specific scalar values change.
  • Hoisting: If an object or function does not depend on component state or props, move its definition outside the component body to maintain a stable reference across all renders.
  • Memoization: When a stable reference is required for a derived object or function that depends on props, utilize useMemo or useCallback. This preserves the memory reference between renders unless the internal dependencies change.
  • Tooling: Enable the exhaustive-deps rule from eslint-plugin-react-hooks. This provides automated analysis to detect when dependencies are missing or incorrectly referenced, preventing common synchronization errors.

Gotcha #2: Missing Dependencies and Silent Failures

React’s useEffect contract guarantees that the cleanup function runs only when one of two conditions occurs: the component unmounts, or a value listed in the effect’s dependency array changes. When a value that the effect uses is omitted from that array, React has no way to detect the change, so the effect never re‑executes and the cleanup never fires. This “missing dependency” pattern produces silent failures that are hard to spot because the component appears to work on the first render but later retains stale resources.

Typical symptom

  • First network request or subscription succeeds, subsequent updates do nothing.
  • Duplicate event listeners, memory growth, or aborted requests that never cancel.
  • Console logs inside the cleanup never appear, even though the code expects them to run.

Illustrative example

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 cleanup
    };
  }, []); // 🚨 query is missing here
}

Because query is not listed, the effect runs only on the initial mount. When the user types a new search term, React does not re‑run the effect, so the previous fetch is never aborted and the UI may display outdated results.

How to resolve the issue

  • Include every variable referenced inside the effect’s callback in the dependency array.
  • Enable the exhaustive-deps rule from eslint-plugin-react-hooks; it flags omitted dependencies automatically.
  • If a variable is a stable primitive (e.g., query, userId), list it directly. For objects, arrays, or functions, either list the primitive parts or memoize the value with useMemo/useCallback before using it as a dependency.
  • When refactoring, verify that any early returns inside the effect do not bypass resource creation without providing a cleanup path.

By ensuring the dependency array accurately reflects every external value the effect reads, React can correctly schedule re‑execution and invoke the cleanup at the appropriate moments, eliminating silent leaks and guaranteeing predictable resource management.

Gotcha #3: Logic Errors with Conditional Returns

When a useEffect creates a resource—such as a timer, subscription, or network controller—React expects the effect to return a cleanup function that releases that resource. An early return that exits the effect body before the cleanup function is defined can leave the resource dangling, because the cleanup function is never attached to the component’s lifecycle.

Consider the following pattern:

useEffect(() => {
  if (!isEnabled) return;               // early exit, no resource created
  const interval = setInterval(tick, 1000);
  if (someOtherCondition) {
    return;                             // ❗ cleanup is skipped
  }
  return () => clearInterval(interval);
}, [isEnabled, someOtherCondition]);

If someOtherCondition evaluates to true, the interval is created but the function returns undefined instead of the cleanup closure. React therefore has no way to invoke clearInterval when the component unmounts or when dependencies change, resulting in a memory leak that can accumulate with each render.

  • Why it happens: JavaScript’s return statement terminates the current function immediately. In an effect, any code after the return—including the cleanup definition—is never executed.
  • Typical symptoms: Duplicate event listeners, growing timer counts, or network requests that never abort, often reported as “resource leaks” in production logs.
  • Detection strategy: Log inside the cleanup function itself and verify that it runs on every dependency change or unmount. If the log never appears, trace all early‑return paths.

To avoid the pitfall, follow these concrete steps:

  1. Place resource creation and cleanup in the same lexical block; do not intermix early returns after the resource is allocated.
  2. If a condition prevents resource allocation, return a no‑op cleanup: return () => {}; This guarantees React always receives a function.
  3. When multiple guard clauses are needed, restructure the effect:
useEffect(() => {
  if (!isEnabled || someOtherCondition) {
    return () => {};                     // safe no‑op cleanup
  }
  const interval = setInterval(tick, 1000);
  return () => clearInterval(interval);
}, [isEnabled, someOtherCondition]);

By ensuring every execution path returns a cleanup function, engineers can satisfy React’s contract and prevent the subtle resource leaks that arise from conditional early returns.

Gotcha #4: Stale Closures vs. Cleanup Bugs

React guarantees that a useEffect cleanup function runs only when the effect is about to be re‑executed because a dependency changed, or when the component unmounts. When developers observe “nothing happens” – no unsubscribe, no cleared interval – the root cause is usually one of two distinct problems.

Failing cleanup function

A cleanup fails when React never reaches the condition that triggers it. This happens when the dependency array does not include a value that actually changes, so the effect never re‑runs and the cleanup is never scheduled. In the SearchResults example, query is used inside the effect but omitted from the dependency list, causing the fetch to fire once and the abort controller to remain active for the lifetime of the component.

  • Symptom: resources (event listeners, timers, subscriptions) persist across renders.
  • Cause: under‑specified dependencies → effect never re‑executes.
  • Detection: the cleanup body never logs or runs; ESLint’s exhaustive-deps rule flags the missing dependency.

Stale closure in a cleanup

A stale closure occurs when the cleanup function runs, but it captures values from the render in which the effect was created. The function’s logic may appear to do nothing because it operates on outdated data. The classic case is a setTimeout that logs count while the dependency array is empty; the timeout’s callback closes over the initial count value, so later updates are invisible, giving the impression that the cleanup never fired.

  • Symptom: logged values or state updates never reflect the latest render.
  • Cause: empty or incomplete dependency array causing the effect’s inner callback to close over stale variables.
  • Detection: the cleanup logs correctly, but the side‑effect (e.g., console.log(count)) shows an old value.

Practical differentiation

To tell the two apart, log directly inside the cleanup function and inside any asynchronous callback created by the effect. If the cleanup log appears on every dependency change, the cleanup is running; if the callback still reports old data, you are dealing with a stale closure.

Remediation steps:

  • Ensure every value referenced inside the effect appears in the dependency array.
  • Memoize objects, arrays, or functions passed as dependencies with useMemo or useCallback to avoid unnecessary re‑creation.
  • When a cleanup must act on the latest state, include that state in the dependency list or use a ref to hold mutable values.
  • Enable eslint-plugin-react-hooks’s exhaustive-deps rule to catch missing dependencies automatically.

By separating “cleanup never invoked” from “cleanup invoked with stale data,” engineers can target the correct fix and avoid the memory‑leak or race‑condition symptoms that often masquerade as each other.

Practical Debugging Strategies

React’s useEffect contract guarantees that a cleanup function runs only when a dependency listed in the effect’s array changes or when the component unmounts. Understanding this contract is the first step before applying any debugging technique.

Why the cleanup may appear silent

  • When a dependency never changes (because its reference stays the same), the effect never re‑runs and the cleanup is never invoked.
  • When a dependency is recreated on every render (e.g., an inline object literal), React treats it as a change on each render, causing the cleanup to fire constantly and potentially masking the intended behavior.
  • Early returns that bypass the cleanup block can leave resources such as timers or subscriptions alive.

Actionable debugging checklist

  • Log inside the cleanup. Add a distinct log statement directly in the returned function to verify whether it runs, and how often:
    useEffect(() => {
      const timer = setInterval(tick, 1000);
      return () => {
        console.log('cleanup: clearing timer');
        clearInterval(timer);
      };
    }, [intervalMs]);
  • Enable exhaustive-deps ESLint rule. This rule flags missing dependencies that cause effects to become stale. Turning it on surface‑level bugs such as a fetch that never aborts because query is omitted from the dependency array.
  • Memoize reference values. If an effect depends on an object, array, or callback, wrap it with useMemo or useCallback so the reference only changes when its contents change:
    const options = useMemo(() => ({
      roomId,
      serverUrl: 'https://chat.example.com'
    }), [roomId]);
    Then list options (or its primitive fields) in the dependency array.
  • Audit early returns. Walk through every conditional branch inside the effect body. Ensure that any path that creates a resource also reaches a corresponding cleanup return. For example:
    useEffect(() => {
      if (!isEnabled) return;
      const id = setInterval(tick, 1000);
      if (someOtherCondition) return; // ❌ skips cleanup
      return () => clearInterval(id);
    }, [isEnabled, someOtherCondition]);
    Refactor so the cleanup is defined before any return, or move the early‑return logic outside the effect.

By first confirming the contract, then systematically logging, enforcing exhaustive-deps, stabilizing references, and verifying that no early return bypasses cleanup, engineers can reliably locate and fix the most common useEffect cleanup failures.

Editorial Policy & Research Methodology

Our findings are based on rigorous internal research, verified industry benchmarks, and direct technical implementation experience from our enterprise client projects. All statistics and technical claims are reviewed by senior engineers before publication to ensure accuracy, transparency, and helpfulness for our readers.

Have an Idea?

Let's Build Something Amazing Together.