React & Next.js

The dependency array — what it actually controls

The array at the end of useEffect isn't a performance hint you can approximate — it's a literal, honest list of every reactive value the effect's own code actually reads. Getting it wrong doesn't crash anything; it just makes the effect quietly wrong.

Intermediate

5 min read

Three shapes, three completely different behaviors

useEffect(() => { /* ... */ });          // no array at all — runs after EVERY render
useEffect(() => { /* ... */ }, []);       // empty array — runs once, after the first render only
useEffect(() => { /* ... */ }, [userId]); // runs after the first render, and again whenever userId changes

No array means "run after every single render, no exceptions" — rarely what's actually wanted, since it means the effect fires even when nothing it depends on changed at all. An empty array means "run exactly once, right after the component's first render, and never again due to a re-render" — the standard shape for a one-time setup (like subscribing to something on mount). A populated array means "run after the first render, and again on any subsequent render where at least one listed value is different from last time" — the shape used the overwhelming majority of the time in real code.

The rule isn't a suggestion: it's "list every reactive value the effect reads"

function SearchBox({ query, onSearch }) {
  useEffect(() => {
    const timeout = setTimeout(() => onSearch(query), 300);
    return () => clearTimeout(timeout);
  }, [query]); // Missing onSearch! — this is a REAL bug, not a style nitpick
}

The dependency array isn't "list the things you think matter" — it's a literal, mechanical requirement: every value from the component's own scope (props, state, or anything derived from them) that the effect's function body actually reads has to be listed. onSearch is read inside the effect but missing from the array here — if SearchBox's parent ever passes a new onSearch function (a genuinely common thing to happen), this effect keeps calling the old, stale onSearch from whenever it last ran, silently using outdated data. This is exactly the class of bug the react-hooks/exhaustive-deps ESLint rule exists to catch automatically — treating its warnings as a real bug report, not a style suggestion to silence, is the actual correct response almost every time it fires.

Objects and functions: a genuinely tricky, common gotcha

function Parent() {
  const [count, setCount] = useState(0);
  const config = { limit: 10 }; // a NEW object, created fresh on every single render
 
  return <Child config={config} />;
}
 
function Child({ config }) {
  useEffect(() => {
    console.log("effect ran");
  }, [config]); // fires on EVERY Parent re-render, not just when limit actually changes
}

{ limit: 10 } is recreated as a brand-new object on every render of Parent, even though its contents never change — and dependency comparison in useEffect uses Object.is (essentially ===), which compares object identity, not deep equality. Two objects with identical contents are still two different objects to ===, so Child's effect re-runs on every single Parent render, not just the renders where config's actual values changed. This is a genuinely common, non-obvious source of "why does this effect run so often" bugs — the fix is either moving the object creation outside the component (if it never needs to change), wrapping it in useMemo (covered in the performance lesson later in this domain), or restructuring to depend on the primitive values inside it (config.limit) instead of the object itself.

Why lying to the array (removing a dependency you actually use) doesn't "fix" anything

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // reads count — but count is missing from the array below!
    }, 1000);
    return () => clearInterval(id);
  }, []); // "fixing" the warning by removing count from the array — this is a real bug, not a fix
}

Suppressing the exhaustive-deps warning by simply removing count from the array doesn't make the effect correct — it makes the effect's closure permanently capture count's value from the very first render (0), and every setInterval tick keeps computing 0 + 1, forever, since the effect never re-runs to capture a fresh count. This is a stale closure bug, and it's the single most common real consequence of fighting the dependency array instead of either listing the real dependency correctly or restructuring the effect (the functional updater form from the state lesson, setCount((prev) => prev + 1), sidesteps needing count in the dependency array at all, since it never reads the outer count directly).

The honest fix when a dependency genuinely shouldn't be listed

useEffect(() => {
  logAnalyticsEvent("page_view", { userId }); // only userId should matter here
}, [userId]); // if logAnalyticsEvent is stable (defined outside the component, or wrapped
                // in useCallback), it's legitimately fine to omit — the lint rule handles this correctly

The dependency-array rule isn't "list everything, always, no matter what" — it's "list every value the effect reads that can genuinely change between renders." A function defined outside any component (a module-level import) or a value truly guaranteed stable (like a setCount function returned from useState, which React guarantees never changes across renders) is legitimately safe to omit, and the ESLint rule already knows this and won't warn about it. The actual discipline is understanding why a value is safe to omit, case by case, rather than reflexively suppressing warnings that are, in the overwhelming majority of real cases, correctly flagging a genuine bug.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What's the actual difference between useEffect(fn), useEffect(fn, []), and useEffect(fn, [userId])?

2. Why does an object created fresh on every render (like `{ limit: 10 }`) as a dependency cause an effect to re-run on every single render, even if its contents never change?

3. Why does removing a dependency that's actually used inside the effect (to silence the exhaustive-deps warning) create a real bug rather than fixing anything?