Skip to main content

Effects and Refs

Effect Dependencies

Written by Published

The dependency array is not a schedule. It is a list of values the effect reads, and React re-runs the effect whenever one of them differs from last time.

The three forms

  1. No array - runs after every render.
  2. Empty array - runs once, after the first render.
  3. Array with values - runs again whenever one of them changes.

All three, counted

jsx

function Deps() {
  const [a, setA] = React.useState(0)
  const [b, setB] = React.useState(0)
  const counts = React.useRef({ every: 0, once: 0, onA: 0 })

  React.useEffect(() => { counts.current.every++ })
  React.useEffect(() => { counts.current.once++ }, [])
  React.useEffect(() => { counts.current.onA++ }, [a])

  return (
    <div>
      <p>a = {a}, b = {b}</p>
      <button onClick={() => setA(a + 1)}>Change a</button>{" "}
      <button onClick={() => setB(b + 1)}>Change b</button>
      <pre style={{ background: "#f1f5f9", padding: 8 }}>
{`no array : ${counts.current.every}
empty [] : ${counts.current.once}
[a]      : ${counts.current.onA}`}
      </pre>
      <p style={{ color: "#64748b" }}>Press "Change b" a few times and watch which counters move.</p>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Deps />)

Changing b moves the first counter but not the third. The effect with [a] does not care about b, and React can tell because a is unchanged.

The infinite loop

An effect that sets state it also depends on will run forever: set state, re-render, dependency changed, run again. React eventually errors with Maximum update depth exceeded.

Never do this

jsx

useEffect(() => {
  setCount(count + 1)   // changes count...
}, [count])             // ...which re-triggers this effect

The same happens more subtly with objects and arrays. {} is a new object every render, so [options] is different every time even when the contents are identical.

Objects break dependency checks

jsx

function Runs() {
  const [tick, setTick] = React.useState(0)
  const runs = React.useRef({ object: 0, primitive: 0 })

  // A new object each render — never equal to the last one.
  const options = { pageSize: 10 }
  React.useEffect(() => { runs.current.object++ }, [options])

  // A primitive compares by value, so this is stable.
  React.useEffect(() => { runs.current.primitive++ }, [options.pageSize])

  return (
    <div>
      <button onClick={() => setTick(tick + 1)}>Re-render ({tick})</button>
      <pre style={{ background: "#f1f5f9", padding: 8 }}>
{`[options]          ran ${runs.current.object} times
[options.pageSize] ran ${runs.current.primitive} times`}
      </pre>
    </div>
  )
}

const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Runs />)

Do not lie to the array

Removing a dependency to stop an effect re-running does not fix anything - it just makes the effect read a stale value from an old render. The lint rule that complains about this is right; the fix is to change what the effect does, not what you declare.

How React compares them

React compares each dependency with the previous render's value using Object.is - the same check === makes, give or take NaN. There is no deep comparison, and there is no way to ask for one.

That single fact explains every surprising re-run: two objects with identical contents are different values, so an effect depending on one runs every render. The fixes are to depend on a primitive inside it, build the object outside the component, or memoise it - covered in the performance section.

useLayoutEffect, briefly

useLayoutEffect has the identical signature and dependency rules, but runs before the browser paints rather than after. That makes it slower - the browser waits for it - and it is the right choice in exactly one situation: when you measure the DOM and immediately change it based on the measurement.

Doing that in a normal effect means the user sees one frame of the unadjusted layout before it jumps. Positioning a tooltip against its trigger is the standard example. For everything else, useEffect is correct and cheaper.

  • List every value from the component that the effect reads.
  • Prefer primitives; objects and arrays compare by identity.
  • Setters from useState are stable and never need listing.
  • Comparison is Object.is - never deep.
  • Use useLayoutEffect only to avoid a visible layout jump.
  • If the list feels wrong, the effect is probably doing too much.