useEffect Dependencies and Cleanup

The useEffect hook lets a function component reach outside of rendering and talk to the outside world — fetching data, subscribing to events, setting timers, or manually touching the DOM. Two things decide whether that hook behaves correctly or becomes a source of bugs: the dependency array, which controls how often the effect re-runs, and the cleanup function, which undoes whatever the effect set up before it runs again or the component unmounts. Get these wrong and you get infinite render loops, stale data, or memory leaks that only show up after the app has been running for a while. This lesson covers both in depth, with the mental model you need to reason about them correctly every time.

Overview: How useEffect Really Works

Rendering in React is supposed to be a pure calculation: given props and state, a component returns JSX describing what the UI should look like. Anything that reaches outside that calculation — talking to the DOM directly, fetching data over the network, subscribing to a WebSocket, starting a timer — is a side effect, and side effects don’t belong in the render body itself. The useEffect hook is where you put them. React calls your effect function after it has rendered and the browser has painted the screen, so effects never block the visual update.

The second argument to useEffect is the dependency array, and it is the single most important thing to understand about the hook. On every render, React compares the new dependency array to the one from the previous render, item by item, using Object.is (essentially ===). If every value is the same, React skips running the effect again. If anything differs, React runs the cleanup function from the last effect (if one was returned) and then runs the new effect. There are three shapes this array can take, and each means something very different:

  • No array at all — the effect runs after every render, with no comparison performed.
  • An empty array [] — the effect runs exactly once, after the first render, because an empty array is always “equal” to the previous empty array.
  • An array with values, e.g. [userId, page] — the effect runs after the first render and again any time one of those specific values changes.

This is why the array is called a dependency array and not a “trigger” array you fill in by hand: it should list every reactive value the effect body actually reads (props, state, and any value derived from them), full stop. React’s own ESLint plugin (eslint-plugin-react-hooks) can enforce this automatically, and you should treat its warnings as correctness bugs, not style nits — omitting a dependency is the single most common source of stale-closure bugs in React apps.

One subtlety trips up almost everyone eventually: Object.is compares objects, arrays, and functions by reference, not by contents. If an effect depends on a freshly built object literal, that object is a brand-new reference on every render, so the effect thinks its dependency changed every single time, even if the underlying values didn’t. The fix is to depend on the primitive values themselves ([userId]) rather than an object or array wrapper built fresh each render.

Hooks also have a hard rule that makes all of this reliable: they must run in the exact same order on every render of a given component. That’s how React matches up each useState call and each useEffect call across renders without you naming them. It’s why hooks can never live inside an if, a loop, or a nested function — doing so would change how many hooks run, or in what order, and React would silently mismatch state and effects between renders.

Syntax

useEffect(() => {
  // effect body — runs after render and paint

  return () => {
    // optional cleanup — runs before the next effect, and on unmount
  };
}, [dependency1, dependency2]);
Part Meaning
First argument A function containing the side effect. React calls it after the DOM has been updated and painted.
Return value (optional) A cleanup function. React calls it before running the effect again, and one final time when the component unmounts.
Second argument (dependency array) Controls how often the effect re-runs: omitted = every render, [] = once on mount, [a, b] = on mount and whenever a or b changes.

You must import the hook explicitly: import { useEffect } from "react";. It can only be called at the top level of a function component or a custom hook — never inside a condition, loop, event handler, or nested function.

Examples

Example 1: Syncing the document title with state

import { useState, useEffect } from "react";

function PageTitle() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

export default PageTitle;

Output:

Renders: You clicked 0 times [Click me]
After 2 clicks, page shows: You clicked 2 times
Browser tab title becomes: "You clicked 2 times"

This effect depends on count. On the first render it sets the tab title to match the initial count. Every time setCount updates state, the component re-renders, React compares the new [count] to the previous one, sees it changed, and runs the effect again — so the document title always stays in sync with what’s on screen, without ever setting it during the render itself.

Example 2: A timer with a cleanup function

import { useState, useEffect } from "react";

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);

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

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

  return <p>Elapsed: {seconds}s</p>;
}

export default Stopwatch;

Output: Renders “Elapsed: 0s” immediately, then “Elapsed: 1s”, “Elapsed: 2s”, and so on once per second.

The dependency array is empty, so the effect starts the interval exactly once, when the component mounts. The function it returns is the cleanup: React calls it right before the component unmounts, which clears the interval. Without that cleanup, the timer would keep calling setSeconds forever, even after the Stopwatch is gone from the screen — a classic memory leak. Note the updater form setSeconds((prev) => prev + 1): it reads the latest state directly from React instead of capturing seconds from the render where the effect was created, so the interval doesn’t need seconds in its dependency array at all.

Example 3: Fetching data that depends on a prop, safely

import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let ignore = false;
    setLoading(true);

    async function fetchUser() {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      const data = await response.json();
      if (!ignore) {
        setUser(data);
        setLoading(false);
      }
    }

    fetchUser();

    return () => {
      ignore = true;
    };
  }, [userId]);

  if (loading) return <p>Loading...</p>;

  return <p>{user.name}</p>;
}

export default UserProfile;

Output: Renders “Loading…” then, once the request resolves, the fetched user’s name, e.g. “Ada Lovelace”.

This effect depends on userId, so switching to a different user re-runs it. useEffect callbacks can’t be async functions themselves (an async function returns a promise, and React expects either nothing or a cleanup function back), so the async logic lives in an inner fetchUser function that the effect calls. The ignore flag guards against a race condition: if userId changes again before the first request finishes, the cleanup sets ignore to true for that stale effect, so its late-arriving response is discarded instead of overwriting the UI with outdated data.

Under the Hood: Mount, Update, and Unmount

It helps to walk through the full lifecycle of a component that uses useEffect:

On mount: React renders the component for the first time and commits the resulting DOM nodes to the page. After the browser has painted, React runs every effect in the component, in the order they were declared. Any cleanup function an effect returns is remembered for later.

On update: When state or props change, React re-renders the component to get a new JSX tree, reconciles it against the previous tree (the Virtual DOM diff), and commits only the necessary DOM changes. Then, for each useEffect call, React compares this render’s dependency array to the previous render’s array. If they’re the same, the effect is skipped entirely — its function isn’t even invoked. If they differ, React first calls the previous cleanup function (so timers, subscriptions, or listeners from the old effect are torn down), and only then runs the new effect function and remembers its new cleanup.

On unmount: When the component is removed from the tree, React calls the most recently stored cleanup function for every effect one last time, then discards the component’s state entirely. This is the only chance an effect gets to release things like intervals, subscriptions, event listeners, or in-flight request flags before the component disappears for good.

Two details make this predictable rather than magical. First, effects run in the order they’re declared, after the paint — so they never delay what the user sees. Second, because dependency arrays are compared positionally, hooks must be called in the same order on every render; that’s the real reason the Rules of Hooks forbid conditional or looped hook calls.

Common Mistakes

Mistake 1: Omitting the dependency array while updating state

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // no dependency array
  });

  return <p>{count}</p>;
}

Without a dependency array, the effect runs after every render. Since it calls setCount, each run triggers another render, which runs the effect again — an infinite loop that pegs the CPU and never stops.

useEffect(() => {
  setCount((c) => c + 1);
}, []); // runs once, right after the initial render

Adding [] makes the effect run exactly once. Using the updater form (c) => c + 1 is good practice here too, since it doesn’t need count as a dependency at all.

Mistake 2: Leaving a used value out of the dependency array

function SearchBox({ query }) {
  useEffect(() => {
    console.log(`Searching for: ${query}`);
  }, []); // "query" is read here but missing from the array

  return null;
}

The effect reads query but the array says “never re-run after mount.” The function closes over the query value from the very first render, so if the prop changes later, the console keeps logging the original, now-stale value — a classic stale closure.

useEffect(() => {
  console.log(`Searching for: ${query}`);
}, [query]);

Listing query tells React to re-run the effect whenever it changes, so the logged value is always current.

Mistake 3: Forgetting to clean up a subscription or timer

useEffect(() => {
  const id = setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);
}, []); // no cleanup returned

The interval is created but never cleared. Even after the component unmounts, it keeps firing and calling setSeconds, which React flags as an update on an unmounted component — and, across many such components, a real memory leak.

useEffect(() => {
  const id = setInterval(() => {
    setSeconds((s) => s + 1);
  }, 1000);

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

Returning a cleanup function that clears the interval guarantees the timer stops the moment the component goes away.

Best Practices

  • Include every prop, state variable, or derived value the effect body reads in the dependency array — let the eslint-plugin-react-hooks exhaustive-deps rule catch omissions instead of disabling it.
  • Depend on primitive values (userId, page) rather than freshly created objects or arrays, since a new object reference on every render defeats the comparison and re-runs the effect constantly.
  • Always return a cleanup function for anything that outlives a single render: setInterval/setTimeout, event listeners, subscriptions, and in-flight requests.
  • Use an ignore flag or AbortController in data-fetching effects to discard responses that resolve after the dependencies (or the component) have already changed.
  • Prefer the updater function form of state setters (setCount((c) => c + 1)) inside effects so the effect doesn’t need the state itself as a dependency.
  • Don’t reach for useEffect to derive a value from existing props or state — compute it directly during render instead; effects are for synchronizing with something outside React.
  • Split unrelated concerns into separate useEffect calls rather than one large effect with a long, mixed dependency array.
  • Never call useEffect (or any hook) inside a condition, loop, or nested function — always at the top level of the component or a custom hook.

Practice Exercises

  • Exercise 1: Write a component WindowWidth that displays the current window.innerWidth and updates live as the browser is resized. Hint: add a "resize" event listener to window inside a useEffect with an empty dependency array, and remove it in the cleanup function.
  • Exercise 2: Take the UserProfile example from this lesson and introduce a bug on purpose by removing userId from the dependency array. Predict, then verify by reasoning through the render sequence, what happens if the component receives a new userId prop — does it re-fetch?
  • Exercise 3: Build a useDebouncedValue(value, delay) custom hook that returns a debounced copy of value, updating only after delay milliseconds have passed without value changing. Use setTimeout inside useEffect with [value, delay] as dependencies, and clear the timeout in the cleanup function on every re-run.

Summary

  • useEffect runs side effects after React renders and the browser paints, keeping side effects out of the render calculation itself.
  • The dependency array controls frequency: omitted runs every render, [] runs once on mount, and [a, b] re-runs whenever a or b changes, compared with Object.is.
  • Every reactive value the effect reads belongs in the dependency array — omitting one causes stale closures that reference outdated props or state.
  • Objects, arrays, and functions created fresh on every render break the dependency comparison; depend on primitives instead.
  • A returned cleanup function runs before the next effect and on unmount — always clean up timers, subscriptions, listeners, and in-flight fetches.
  • Use an ignore flag or AbortController to prevent race conditions in effects that fetch data based on changing props.