Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 23 of 38

Effect dependencies and cleanup

Master the effect dependency array in React: cleanup between runs, stale values, infinite loops, objects as dependencies and updater functions.

20 minEffects and dataUpdated 24 October 2026

By the end of this lessonList the right dependencies for any effect, write the matching cleanup, and avoid stale values and infinite loops.

The previous lesson showed what effects are for. This one is about writing them correctly, which mostly means getting the dependency array right and pairing every setup with a cleanup. Most "weird effect" bugs (stale values, infinite loops, listeners piling up) come from these two things.

Dependencies are not a choice

Think of an effect as a statement of what it synchronises: "keep the connection in step with roomId". The dependency array is not a performance dial that you tune; it must list everything from the component that the effect reads and that can change: props, state, and any variable calculated from them.

An effect that forgets a dependency still runs, but with a stale snapshot:

jsx
import { useEffect, useState } from "react";

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

  useEffect(() => {
    console.log("effect sees count =", count);
  }, []);                                // wrong: count is used but not listed

  return <button className="add" onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

export default App;
result
Count: 3
CONSOLE
effect sees count = 0
effect sees count = 0

The screen shows 3, but the effect only ran at the start (twice because of StrictMode), when count was 0. Add count to the array and it reports every change. The exhaustive-deps lint rule from the React hooks lint plugin catches this for you (the Vite template turns on only the rules-of-hooks rule, so add this one to your lint setup), and you should treat its warnings as bugs. If the rule asks for a dependency that you believe you do not want, the answer is almost never to silence the rule; it is to change the code (see below).

Cleanup runs before the next effect

When dependencies change, React does not just run the effect again. The order is:

  1. Run the cleanup of the previous effect, with the old values.
  2. Run the new effect, with the new values.

When the component is removed, only the cleanup runs. Here is a pretend chat connection that depends on the room:

jsx
import { useEffect, useState } from "react";

function ChatRoom({ roomId }) {
  useEffect(() => {
    console.log("connect to " + roomId);
    return () => console.log("disconnect from " + roomId);
  }, [roomId]);

  return <p>Welcome to the {roomId} room!</p>;
}

function App() {
  const [roomId, setRoomId] = useState("general");
  return (
    <div>
      <select value={roomId} onChange={e => setRoomId(e.target.value)}>
        <option value="general">general</option>
        <option value="music">music</option>
        <option value="travel">travel</option>
      </select>
      <ChatRoom roomId={roomId} />
    </div>
  );
}

export default App;
result
general
music
travel
Welcome to the travel room!
CONSOLE
connect to general
disconnect from general
connect to general
disconnect from general
connect to music
disconnect from music
connect to travel

Reading the log: after StrictMode's connect/disconnect/connect for general at the start, switching to music printed "disconnect from general" then "connect to music", and switching to travel disconnected from music before connecting. The cleanup used the old roomId because it belongs to the effect that created it. That is why cleanup can reliably undo exactly what its own setup did.

Write the cleanup as the mirror image of the setup:

Setup Cleanup
addEventListener removeEventListener (same function)
setInterval, setTimeout clearInterval, clearTimeout
open a connection or subscription close it, unsubscribe
start a fetch cancel or ignore its result (next lessons)
show a modal or lock scrolling restore the previous state

If an effect has nothing to undo, it needs no cleanup.

Objects and functions as dependencies

React compares dependencies with Object.is. For numbers and strings that means equal values. For objects, arrays and functions it means the same object in memory. A new object is created on every render, so it is always "different":

jsx
import { useEffect, useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const options = { theme: "dark" };            // a new object on every render

  useEffect(() => {
    console.log("effect ran, options.theme =", options.theme);
  }, [options]);                                // changes identity on every render

  return <button onClick={() => setCount(count + 1)}>Re-render ({count})</button>;
}

export default App;
result
Re-render (1)
CONSOLE
effect ran, options.theme = dark
effect ran, options.theme = dark
effect ran, options.theme = dark

The effect ran for the first render (twice, StrictMode) and then again after the single click, although options never changed in content. In real code this produces effects that reconnect, refetch or reset on every keystroke. The fixes, from best to worst:

  1. Move the object inside the effect, if only the effect uses it. The effect then depends on the primitive values it actually reads.
  2. Move it outside the component, if it never depends on props or state (a module-level constant is created once).
  3. Depend on the primitive pieces: [options.theme], not [options].
  4. Memoise it with useMemo (for objects) or useCallback (for functions), covered in the memoization lesson. It is a fix, not a first choice.
jsx
import { useEffect, useState } from "react";

const DEFAULTS = { theme: "dark" };             // outside the component: one object, ever

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

  useEffect(() => {
    const local = { size: 10 };                 // inside the effect: not a dependency
    console.log("effect ran:", DEFAULTS.theme, local.size);
  }, []);                                       // nothing reactive is read

  return <button onClick={() => setCount(count + 1)}>Re-render ({count})</button>;
}

export default App;
result
Re-render (1)
CONSOLE
effect ran: dark 10
effect ran: dark 10

Now clicking the button does not run the effect again. A function declared in the component has the same problem as an object (new each render), and the same cures apply: move it into the effect, outside the component, or wrap it in useCallback.

Removing a dependency with an updater function

Sometimes you really only want to react to a timer, but the callback needs the latest state. Instead of listing the state (which would restart the timer every tick), pass an updater function to the setter, which receives the newest value:

jsx
import { useEffect, useState } from "react";

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);               // no need to read count
    }, 1000);
    return () => clearInterval(id);
  }, []);                                 // runs once: the interval is never restarted

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

export default App;

If the effect read count directly (setCount(count + 1)), it would need count as a dependency, and the interval would be torn down and recreated on every tick. Updater functions are the cleanest way out. When an effect must read the latest value of something without re-running (rare), React offers an experimental useEffectEvent; for now, restructure to avoid it.

Infinite loops

An effect that sets state it also depends on, with nothing to stop it, loops forever:

jsx
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);      // render, effect, set state, render, effect, ...
}, [count]);

Each state change triggers a render, which triggers the effect, which changes the state again. React stops after a large number of nested updates and throws "Maximum update depth exceeded". The usual causes:

  • setting state in an effect with no dependency array (the effect runs after every render),
  • an object or array dependency created during render (new each time),
  • an effect that "derives" state from other state; calculate it during rendering instead.

The cure is to ask whether this should be an effect at all. See the next-but-one lesson, You might not need an effect.

Several effects instead of one

Each effect should do one job and have its own dependencies. Combining unrelated work in one effect forces it to re-run whenever any of its dependencies changes:

jsx
// Better: two effects, each with exactly its own dependencies
useEffect(() => { logVisit(pageId); }, [pageId]);
useEffect(() => { document.title = title; }, [title]);

A checklist for every effect

  1. What is this effect synchronising with? (Say it in words.)
  2. Which props, state and derived values does it read? Those are the dependencies.
  3. What does it start that must be stopped? Write the cleanup.
  4. Does it still work if it runs, stops and runs again (StrictMode)?
  5. Is it really needed, or can the work be done during render or in a handler?

Common mistakes

  • Leaving out dependencies to "stop the effect from running", then reading stale values.
  • Silencing the exhaustive-deps warning with a comment instead of fixing the cause.
  • Putting an object, array or function created during render in the dependencies.
  • Forgetting the cleanup, or writing one that does not exactly undo the setup (removing a different function from removeEventListener).
  • Setting state inside an effect on every run, creating a loop.
  • Expecting the cleanup to see new values; it sees the values of its own render.

Exercise

  1. Add a "Pause" button to the ticking clock from the previous lesson, with running state as a dependency, and make sure the interval is cleared correctly.
  2. Add a keydown listener on window that shows the last key pressed; list the right dependencies and verify in the Console that only one listener exists.
  3. Change the options example so that the effect runs only when options.theme changes.
  4. Build a ChatRoom effect that depends on both roomId and serverUrl and print the connect and disconnect messages as you change each one.

Key points

  • Dependencies are not a choice: list every prop, state value and variable derived from them that the effect reads, and let the lint rule check.
  • Before an effect runs again, React runs the previous effect's cleanup with the old values, then runs the effect with the new ones.
  • Objects, arrays and functions created during render are new every time, so as dependencies they make the effect run on every render; move them inside the effect or out of the component.
  • To read the latest state without depending on it, use an updater function such as setCount(c => c + 1).

Check yourself

1. When an effect with dependencies re-runs, what happens first?
2. Why is an object created inside the component a problem as a dependency?
3. How can an interval callback add 1 to count without listing count as a dependency?
4. What does the exhaustive-deps lint rule do?