ReactMedium

How does the useEffect dependency array work?

01

The Short Answer

The useEffect dependency array tells React when to re-run the effect. React compares the current dependency values with the previous render's values using Object.is (shallow comparison). If any value changed, the effect runs again. An empty array means "run only on mount." No array means "run after every render." The dependency array is how you synchronize your effect with specific pieces of state or props.

02

The Three Modes

The dependency array controls the effect's lifecycle. There are three distinct behaviors depending on what you pass (or don't pass) as the second argument.

three-modes.tsxtypescript
// Mode 1: No dependency array — runs after EVERY render
useEffect(() => {
  console.log('Runs after every single render');
});

// Mode 2: Empty array — runs ONCE on mount, cleanup on unmount
useEffect(() => {
  console.log('Runs once after initial render');
  return () => console.log('Cleanup on unmount');
}, []);

// Mode 3: With dependencies — runs when any dependency changes
useEffect(() => {
  console.log(`Query changed to: ${query}`);
  fetchResults(query);
  return () => console.log('Cleanup before next run or unmount');
}, [query]); // Only re-runs when 'query' changes
Dependency ArrayWhen Effect RunsUse Case
Not providedAfter every renderRarely needed — logging, debugging
[] (empty)Once on mountSubscriptions, event listeners, one-time setup
[a, b, c]When a, b, or c changesFetching data when params change, syncing with external systems
03

How React Compares Dependencies

React uses Object.is() to compare each dependency with its value from the previous render. For primitives (strings, numbers, booleans), this works intuitively — 'hello' === 'hello' is true. For objects and arrays, it compares references — two objects with identical content are still different if they're different references in memory.

comparison-behavior.tsxtypescript
function SearchPage({ userId }: { userId: string }) {
  const [query, setQuery] = useState('');

  // ✅ Primitives — compared by value
  // Effect re-runs only when the actual string/number changes
  useEffect(() => {
    fetchResults(userId, query);
  }, [userId, query]); // 'alice' === 'alice' → skip, 'alice' !== 'bob' → run

  // ⚠️ Objects — compared by reference
  const filters = { category: 'books', sort: 'date' };
  // This object is recreated every render → new reference → effect runs EVERY time!
  useEffect(() => {
    applyFilters(filters);
  }, [filters]); // Always a new object → always "changed" → infinite loop risk

  // ✅ Fix: memoize the object or use primitive dependencies
  const filters = useMemo(() => ({ category: 'books', sort: 'date' }), []);
  useEffect(() => {
    applyFilters(filters);
  }, [filters]); // Now stable — same reference between renders
}

The reference trap

Objects, arrays, and functions created during render are new references every time. If you put them in a dependency array without memoization, the effect runs on every render — defeating the purpose of dependencies entirely. Either memoize with useMemo/useCallback, or depend on the primitive values inside the object instead.

04

The Cleanup Function

The function you return from useEffect is the cleanup function. It runs in two situations: before the effect re-runs (when dependencies change), and when the component unmounts. This is where you cancel subscriptions, remove event listeners, abort fetch requests, or clear timers. Without cleanup, you get memory leaks and stale callbacks.

cleanup.tsxtypescript
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    // Setup: connect to the chat room
    const connection = chatAPI.connect(roomId);
    console.log(`Connected to room: ${roomId}`);

    // Cleanup: disconnect when roomId changes or component unmounts
    return () => {
      connection.disconnect();
      console.log(`Disconnected from room: ${roomId}`);
    };
  }, [roomId]);

  // Timeline when roomId changes from 'general' to 'random':
  // 1. Cleanup runs: "Disconnected from room: general"
  // 2. Effect runs: "Connected to room: random"
}

// Abort controller pattern for fetch
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

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

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setUser)
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });

    // Cleanup: abort the fetch if userId changes before it completes
    return () => controller.abort();
  }, [userId]);
}

The abort controller pattern is critical for data fetching. Without it, if the user navigates quickly (changing userId multiple times), old requests might resolve after newer ones — setting stale data. The cleanup aborts the outdated request so only the latest one's response is used.

05

Common Dependency Mistakes

🔇

Omitting dependencies to prevent re-runs

Developers often leave values out of the dependency array to stop the effect from running too often. This creates stale closures — the effect uses outdated values from a previous render, causing subtle bugs.

Include all values used inside the effect. If the effect runs too often, restructure the code — move the value inside the effect, use a ref, or split into multiple effects.

♾️

Object/array dependencies causing infinite loops

Putting an object or array literal in the dependency array causes the effect to run every render (new reference each time). If the effect also sets state, you get an infinite render loop.

Memoize objects with `useMemo`, functions with `useCallback`, or depend on primitive values extracted from the object instead.

🧹

Forgetting cleanup for subscriptions

Setting up an event listener or WebSocket connection without returning a cleanup function means the listener accumulates on every re-run — you end up with dozens of duplicate listeners.

Always return a cleanup function that undoes the setup. For event listeners: `return () => window.removeEventListener(...)`. For timers: `return () => clearInterval(...)`.

📦

Setting state for derived data

Using useEffect to compute a value from props/state and store it in another state variable. This causes an extra render and is unnecessarily complex.

Compute derived values directly during render: `const fullName = firstName + ' ' + lastName;` — no effect needed. Use `useMemo` if the computation is expensive.

06

The Mental Model

Think of useEffect as synchronizing your component with an external system. The dependency array answers the question: "What values from my component does this external system depend on?" When those values change, the synchronization needs to re-run. The cleanup tears down the old synchronization before setting up the new one.

  • Effect = synchronize with something external (API, DOM, subscription, timer)
  • Dependencies = the component values that the external system cares about
  • Cleanup = tear down the previous synchronization before re-syncing
  • Not for: derived state, transforming data, or handling events (those aren't synchronization)
07

Why Interviewers Ask This

The dependency array is where most useEffect bugs live. Interviewers ask this to check whether you understand how React decides when to re-run effects, can identify stale closure bugs from missing dependencies, know the difference between reference and value equality for dependencies, understand the cleanup lifecycle, and can avoid common anti-patterns like using effects for derived state. It's a practical question that directly maps to real bugs in production React code.

Quick Revision Cheat Sheet

No array: Runs after every render — rarely what you want

Empty []: Runs once on mount, cleanup on unmount

[a, b]: Runs when a or b changes (Object.is comparison)

Objects in deps: New reference every render — memoize or use primitives

Cleanup: Runs before re-run and on unmount — cancel subscriptions, abort fetches

Rule: Include everything from component scope that the effect reads