Skip to content

useEffect: Dependencies, Cleanup, and Common Traps

When effects run, how dependency arrays work, reference stability traps, and avoiding stale closures.

Rajesh DhimanFebruary 08, 2026~16 min
On this page

30‑second interview answer

useEffect lets you run side effects after React commits updates to the DOM—things like data fetching, subscriptions, timers, and manual DOM work. The dependency array controls when the effect runs. Cleanup runs before the next effect (and on unmount) to avoid leaks. Most bugs come from incorrect dependencies and stale closures.


When does useEffect run? (The React Lifecycle Model)

Understanding the exact sequence of React phases separates junior developers from senior engineers:

[ Render Phase ]      React calls your component to calculate the Virtual DOM.
       │              (Must be pure: NO DOM mutation, NO side effects)
       ▼
[ Commit Phase ]      React applies diffs to the real browser DOM.
       │
       ▼
[ Layout Phase ]      useLayoutEffect runs synchronously.
       │              (DOM updated, but screen not yet painted; blocks paint)
       ▼
[ Browser Paint ]     Browser paints pixels to the screen.
       │              (User visually sees the updated UI)
       ▼
[ Passive Effects ]   useEffect runs asynchronously after paint.
                      (Data fetching, analytics, non-blocking subscriptions)

Next Re-render Execution Flow:

  1. State changes → Component re-renders with new props/state.
  2. Commit phase updates DOM and browser paints.
  3. Previous effect cleanup function runs (using old closed-over values).
  4. Current effect function runs (using new closed-over values).

Component Unmount Flow:

  • Final effect cleanup function executes before the component is detached from the DOM tree.

useLayoutEffect vs useEffect

  • useLayoutEffect: Runs synchronously immediately after DOM mutation but before paint. Use ONLY when measuring layout (e.g. tooltip position, scroll clamping) to prevent visual flicker.
  • useEffect: Runs asynchronously after paint. Always default to useEffect to avoid blocking browser rendering.

Dependency array: what it really means

React compares each dependency with its previous value using Object.is(prevDep, nextDep) (shallow reference equality).

1) No dependency array

useEffect(() => {
  // Runs after EVERY commit/render
});

2) Empty dependency array []

useEffect(() => {
  // Runs ONCE after initial mount
  return () => {
    // Cleanup runs ONCE on unmount
  };
}, []);

3) With dependencies

useEffect(() => {
  // Runs on mount AND whenever userId or token changes via Object.is comparison
}, [userId, token]);

Golden Rule: Your dependencies array must include every reactive value (props, state, and functions derived from them) read inside the effect callback.


The Object & Function Reference Trap (Infinite Re-render Loops)

Because React checks dependencies with Object.is, non-primitive data types (objects, arrays, functions) create new memory references on every render:

function UserProfile({ userId }) {
  const [data, setData] = useState(null);
 
  // ❌ BAD: options is created as a NEW object reference on every render!
  const options = { timeout: 3000 };
 
  useEffect(() => {
    fetchUserData(userId, options).then(setData);
    // When setData updates state -> component re-renders -> new options object
    // -> Object.is(prevOptions, nextOptions) is false -> Effect runs again -> INFINITE LOOP!
  }, [userId, options]);
}

How to fix reference stability traps:

  1. Move static objects/functions outside the component:
    const DEFAULT_OPTIONS = { timeout: 3000 };
  2. Move dependencies inside the effect:
    useEffect(() => {
      const options = { timeout: 3000 };
      fetchUserData(userId, options).then(setData);
    }, [userId]);
  3. Decompose to primitive dependencies: Instead of depending on an entire object [user], depend on the primitive properties you actually use: [user.id, user.role].
  4. Memoize with useMemo or useCallback if the object or function must be created inside the component body and passed across multiple hooks or children.

Cleanup (critical for interviews)

Cleanup runs:

  • before the effect re-runs (cleaning up previous execution)
  • when the component unmounts

Typical patterns:

Subscriptions

useEffect(() => {
  const unsub = store.subscribe(() => {
    // ...
  });
  return () => unsub();
}, []);

Timers

useEffect(() => {
  const id = setInterval(() => {
    // ...
  }, 1000);
  return () => clearInterval(id);
}, []);

The most common bug: stale closures

This happens when your effect “captures” old state/props because dependencies were omitted.

Example:

function Example() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // ❌ Always prints 0 because it closed over the initial render's count!
    }, 1000);
 
    return () => clearInterval(id);
  }, []); // ❌ count missing from dependencies
}

Fix 1: include dependencies

useEffect(() => {
  const id = setInterval(() => console.log(count), 1000);
  return () => clearInterval(id);
}, [count]); // Re-establishes interval each time count changes

Fix 2: use functional state updates (Preferred for counters/accumulators)

useEffect(() => {
  const id = setInterval(() => {
    setCount((prevCount) => prevCount + 1); // Reads latest state at execution time!
  }, 1000);
  return () => clearInterval(id);
}, []); // Safe with empty dependency array

Fix 3: store mutable latest value in a ref

const latestCount = useRef(count);
useEffect(() => {
  latestCount.current = count;
}); // Syncs ref on every render
 
useEffect(() => {
  const id = setInterval(() => {
    console.log(latestCount.current); // Reads current ref value without re-triggering effect
  }, 1000);
  return () => clearInterval(id);
}, []);

Fetching data safely & avoiding race conditions

In real applications, network responses may arrive out of order (e.g. user switches tab from ID 1 to ID 2, but request 1 resolves after request 2).

Production Pattern: AbortController

useEffect(() => {
  const controller = new AbortController();
 
  async function loadData() {
    try {
      const res = await fetch(`/api/user/${userId}`, { signal: controller.signal });
      const user = await res.json();
      setUser(user);
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    }
  }
 
  loadData();
 
  // Abort pending request if userId changes or component unmounts
  return () => controller.abort();
}, [userId]);

React Strict Mode (why effects run twice in dev)

In development (<React.StrictMode>), React mounts, unmounts, and re-mounts every component:

  1. Component mounts → Effect runs
  2. Effect cleanup runs
  3. Effect runs again

This intentional double-invocation verifies that your effect is idempotent and that cleanups properly mirror setup logic (e.g. unsubscribing from event listeners, clearing timers, resetting connections).


Senior Interview Puzzle: Parent vs Child Lifecycle Execution Order

Question: Given the following parent and child components, what is the exact output sequence in the console on initial mount?

function Child() {
  console.log('1: Child Render');
  useEffect(() => {
    console.log('2: Child Effect');
    return () => console.log('3: Child Cleanup');
  }, []);
  return <div>Child</div>;
}
 
function Parent() {
  console.log('4: Parent Render');
  useEffect(() => {
    console.log('5: Parent Effect');
    return () => console.log('6: Parent Cleanup');
  }, []);
  return <Child />;
}

Step-by-Step Trace:

  1. Render Phase (Top-Down):
    • React calls Parent: prints 4: Parent Render.
    • JSX encounters <Child />, React calls Child: prints 1: Child Render.
  2. Commit Phase:
    • React attaches Child and Parent DOM nodes.
  3. Passive Effects Phase (Bottom-Up):
    • React flushes child effects before parent effects so that when the parent effect runs, all children are fully mounted and synchronized.
    • Child effect executes: prints 2: Child Effect.
    • Parent effect executes: prints 5: Parent Effect.

Final output on initial mount: 4: Parent Render → 1: Child Render → 2: Child Effect → 5: Parent Effect.


Summary checklist

  • I can explain the distinction between Render, Layout (useLayoutEffect), and Paint (useEffect).
  • I know why omitting dependencies causes stale closures and how functional updates or refs resolve it.
  • I understand how Object.is reference equality creates infinite re-render loops with objects/functions.
  • I can handle asynchronous race conditions using AbortController.
  • I can explain why child effects execute before parent effects on mount.

Authoritative Standards & References


Written by Rajesh Dhiman at Eunix Tech for the Frontend Interview preparation track.

Companion Book

Looking for structured, offline interview prep?

Free online lessons • Portable Amazon Kindle / Paperback

Frontend Interview Guide (2025 Edition) cover
Recommended for React & Frontend Rounds

Frontend Interview Guide (2025 Edition)

By Rajesh Dhiman

Comprehensive preparation covering React internals, Web Performance, CSS architecture, and system design questions.

View on Amazon ↗Kindle & Paperback
JavaScript Interview Guide cover
Recommended for Core JS Rounds

JavaScript Interview Guide

By Rajesh Dhiman

Deep dive into the Event Loop, microtask draining, closures, prototypal inheritance, and asynchronous execution patterns.

View on Amazon ↗Kindle & Paperback

Note: All lessons on Frontend Interview remain completely free to read online. As an Amazon Associate, we may earn from qualifying purchases.

RD

Written by Rajesh Dhiman

Authored by Rajesh Dhiman and engineered at Eunix Tech. Focused on deep-dive frontend engineering, JavaScript runtime internals, React architecture, and web performance.