useEffect and the Dependency Array

Synchronising with the outside world, cleanup functions, and avoiding effect loops.

What you'll learn

  • What an effect is for — and what it is not for
  • How the dependency array controls when an effect runs
  • Why cleanup exists, and when React calls it
  • Why your effect appears to run twice in development

What effects are for

Rendering should be pure: given the same props and state, a component returns the same JSX and touches nothing else. But real apps must talk to things outside React — timers, subscriptions, the document title, a network request.

useEffect is the escape hatch for that. It runs after React has updated the screen:

import { useEffect, useState } from "react";

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

  // Synchronise an outside thing (the tab title) with our state.
  useEffect(() => {
    document.title = count + " clicks";
  }, [count]);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>{count} clicks — check the preview tab's title</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

The dependency array

The second argument decides when the effect re-runs. There are three forms, and the difference is the whole feature:

| Form | Runs | | --- | --- | | useEffect(fn) | after every render | | useEffect(fn, []) | once, after the first render | | useEffect(fn, [a, b]) | after the first render, then whenever a or b changes |

React compares each dependency with its previous value using Object.is — the same identity comparison as everywhere else, which is why a new object or function in the list makes the effect run every time.

Watch the console as you type and click:

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("");

  useEffect(() => {
    console.log("no array — after every render");
  });

  useEffect(() => {
    console.log("empty array — only on mount");
  }, []);

  useEffect(() => {
    console.log("[count] — count is now " + count);
  }, [count]);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16, fontSize: 13 }}>
      <button onClick={() => setCount(count + 1)}>Increment ({count})</button>
      <input
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="Type here"
        style={{ marginLeft: 8 }}
      />
    </div>
  );
}

Typing re-renders the component, so only the first effect logs. Clicking Increment logs the first and the third. The second never logs again after mount.

Every message appears twice when the sandbox first loads — that is React's development double-run, explained further down.

Do not lie to the dependency array. If the effect reads a value, that value belongs in the list. Leaving it out to stop the effect running gives you an effect that reads stale values — a harder bug than the one you avoided.

Cleanup

If an effect starts something ongoing, it must return a function that stops it. React calls that cleanup before the next run of the effect, and again when the component unmounts.

Without it, this interval would keep firing forever, and a second one would start every time the effect re-ran:

import { useEffect, useState } from "react";

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

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

    // Stop the old interval before starting a new one, and on unmount.
    return () => clearInterval(id);
  }, []);

  return <p>alive for {seconds}s</p>;
}

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <button onClick={() => setShow(!show)}>{show ? "Unmount" : "Mount"}</button>
      {show && <Ticker />}
    </div>
  );
}

Unmount and remount the ticker: it restarts from zero, and no stray interval is left behind. Anything you subscribe to, open, or schedule needs the same treatment.

Why it runs twice

In development, React mounts each component, unmounts it, and mounts it again. So your effect runs, cleans up, and runs a second time — deliberately.

It is not a bug, and it is not something to work around. It is a test: an effect that breaks when run twice is an effect that is missing cleanup, and that bug would appear in production anyway the first time the component remounted. In a production build the effect runs once.

If you have ever seen two identical requests in the network tab, this is why.

You might not need an effect

Effects are frequently used for things that are not effects at all. The most common mistake is deriving a value:

// Unnecessary: an extra render, and a state that can go stale.
const [fullName, setFullName] = useState("");
useEffect(() => {
  setFullName(first + " " + last);
}, [first, last]);

// Just calculate it while rendering.
const fullName = first + " " + last;

If a value can be computed from props and state, compute it during render. Save effects for genuinely external things. Setting state in an effect that depends on that same state is also how you get an infinite loop: render, effect, set state, render, effect...

Common mistakes

  • A missing dependency. The effect reads a stale value.
  • No cleanup. Timers and subscriptions pile up.
  • An effect to derive state. Compute it during render instead.
  • An object or function in the deps. A new identity each render means the effect runs every render — see useRef, useMemo, and useCallback.

Exercise

Two bugs: the interval is never cleared, so it keeps speeding up as count changes, and the effect is missing a dependency it reads. Fix both so the counter ticks once per second.

import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);

  useEffect(() => {
    setInterval(() => setCount((c) => c + step), 1000);
  }, []);

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <p>count: {count}</p>
      <button onClick={() => setStep(step + 1)}>step is {step}</button>
    </div>
  );
}