DEV Community

Cover image for Maximum Update Depth Exceeded: 4 useEffect Dependency Bugs That All Passed Review
Info Inlet
Info Inlet

Posted on

Maximum Update Depth Exceeded: 4 useEffect Dependency Bugs That All Passed Review

Here's a dependency array that crashed React the first time a user hovered a button:

useLayoutEffect(() => {
  if (!tip || !boxRef.current) return
  const { width, height } = boxRef.current.getBoundingClientRect()
  const x = /* … clamp to the window … */
  const y = /* … above the control, or below it … */
  if (!pos || pos.x !== x || pos.y !== y) setPos({ x, y })
}, [tip, pos])
Enter fullscreen mode Exit fullscreen mode

Nothing in it is wrong by the usual rules. It reads tip, it reads pos, and both are listed. react-hooks/exhaustive-deps is perfectly happy. There's even a guard — that if exists specifically to stop the loop.

It loops anyway. Every time. Maximum update depth exceeded, the first time any tooltip appeared.

I went looking through our own history for the others, and there were three more — same category, four completely different disguises. A spreadsheet that yanked your scroll back to where you started. An onboarding step that showed an error for a request that was never sent. A guided tour that pointed at nothing.

All four shipped. All four passed review. Three of them exist because someone did what the lint rule told them to.

Here's what's actually going on, and the four questions I now ask any dependency array.


The gap the linter can't see

react-hooks/exhaustive-deps answers one question:

What does this effect read?

Your dependency array is asked a different one:

When should this effect run again?

Most of the time those two questions have the same answer, which is why the rule works and why we all stopped thinking about it. Every bug below is a case where they come apart — and because the linter only checks the first question, it will cheerfully sign off on the wrong answer to the second. Worse, it will demand it.


Bug 1: the dependency you measure

Back to the tooltip. Why doesn't the guard hold?

Because x and y are computed from getBoundingClientRect() on an element that has a CSS transform on it. Measuring a translated box hands back subpixel-different widths run to run247.99998474121094, then 248.00001525878906. So pos.x !== x is true. Forever.

The sequence is:

  1. effect runs, measures, sets pos
  2. pos changed, so the effect re-runs
  3. it measures again, gets a value a ten-thousandth of a pixel different
  4. the guard says "changed!", sets pos
  5. → 2

React tears this down at its update-depth limit, which is why it presents as a crash rather than as a slow page.

The fix is one character shorter than the bug:

// Keyed on `tip` ALONE. Re-running on `pos` and comparing the result to decide whether to settle is
// the shape that deadlocks: measuring a translated box hands back subpixel-different widths run to
// run, so the "has it changed?" test never came out false and React tore the render loop down at its
// update-depth limit. One measurement per hover is all this needs — the control does not move under
// the pointer, and anything that WOULD move it (scroll, resize, click) dismisses the tooltip.
useLayoutEffect(() => {
  if (!tip || !boxRef.current) return
  const { width, height } = boxRef.current.getBoundingClientRect()
  // …
  // Whole pixels: keeps the text crisp, and keeps this off the subpixel treadmill above.
  setPos({ x: Math.round(x), y: Math.round(y) })
}, [tip])
Enter fullscreen mode Exit fullscreen mode

Two changes worth separating, because only one of them is the actual fix:

  • [tip], not [tip, pos]. This is the fix. The effect doesn't need to re-run when its own output changes; it needs to run once per hover. The control cannot move under the pointer, and anything that would move it — scroll, resize, click — dismisses the tooltip.
  • Math.round. This is the belt-and-braces. It also happens to make the text crisper, because subpixel-positioned text is subpixel-rendered text.

Question 1: is any dependency here something I measured rather than something I was given?

Measured values — getBoundingClientRect, scrollHeight, offsetWidth, anything downstream of layout — are not stable identities. A dependency you measure is a dependency that will never equal itself.

The general shape to be afraid of: an effect that takes its own output as an input, and uses a comparison to decide when to stop. That comparison is now load-bearing, and it's comparing floats you didn't produce.


Bug 2: the dependency whose identity churns

Different file, same family. A spreadsheet keeps the active cell in view — necessary, because the grid is virtualised, so the selected cell may not be rendered at all after keyboard nav or a formula jump.

useEffect(() => {
  const el = gridRef.current
  if (!el) return
  const di = display.indexOf(active.r)
  if (di < 0) return
  const z = zoom / 100
  const top = offsetTop(di) * z
  const bottom = top + rowHeight(active.r) * z
  if (top < el.scrollTop) el.scrollTop = top
  else if (bottom > el.scrollTop + el.clientHeight - HEADER_H * z) /* … */
}, [active.r, display, zoom, offsetTop, rowHeight])
Enter fullscreen mode Exit fullscreen mode

Textbook exhaustive deps. It reads five things; it lists five things. The linter would have added the last four if you hadn't.

The symptom: scroll away from the cell you clicked, and the view snaps straight back to it.

Why: display is a fresh array and offsetTop / rowHeight are fresh callbacks every time the view grid grows — and growing the view grid is precisely what scrolling toward an edge does. So the sequence is: you scroll → the sheet grows → the memo produces a new array identity → the effect re-runs → it scrolls you back to active.r. In both directions, because growing columns invalidates the same memo as growing rows.

The intent was always "when the selection moves". The array said "when any of these five identities change". Those diverged the moment virtualisation was added, and nothing flagged it, because nothing could.

// The layout facts the scroll-into-view effect below needs, held in a ref so they can't RE-TRIGGER
// it. `display` is a fresh array (and offsetTop/rowHeight fresh callbacks) every time the view grid
// grows — which is exactly what scrolling toward an edge does — so having them as deps meant every
// scroll that grew the sheet yanked the view straight back to the cell you had clicked, in both
// directions (growing COLUMNS invalidates the same memo as growing rows). Scrolling away from the
// selection is deliberate; only a change of selection should pull the view back.
const scrollLayout = useRef({ display, zoom, offsetTop, rowHeight })
scrollLayout.current = { display, zoom, offsetTop, rowHeight }

useEffect(() => {
  const el = gridRef.current
  if (!el) return
  const { display, zoom, offsetTop, rowHeight } = scrollLayout.current
  // … same body …
}, [active.r])
Enter fullscreen mode Exit fullscreen mode

This is the pattern the useEffectEvent RFC exists to make official, and you can have it today with four lines and a ref. The rule it encodes:

Question 2: does this effect react to this value, or does it merely read it?

Things it reacts to go in the array. Things it reads go in a ref. The linter cannot tell these apart and will file everything under "reacts to".

If you take one thing from this article, take this one. It's the most common version by a distance, and unlike the subpixel case it never crashes — it just makes your app feel possessed.


Bug 3: the dependency that is your own echo

This one produced the strangest bug report of the four: typing a single letter into "What do you want to make?" printed "Could not check that just now. Try again" — instantly, with no request in flight, and none ever sent.

The setup is one every form in every wizard has. The parent owns the text so it survives stepping away from the step. The child is controlled, and pushes changes up:

const [query, setQuery] = useState(need)

useEffect(() => { setQuery(need) }, [need])   // "sync down when the question changes"
Enter fullscreen mode Exit fullscreen mode

The parent hands that value straight back as need. So need changes for two completely different reasons, and this effect cannot tell them apart:

  • somebody navigated back into this step with a saved sentence → genuinely new, should reset
  • the user typed a character, which we pushed up, which came back down → our own echo, should do nothing

Every keystroke re-entered the reset effect. Results wiped. ranFor set to half-typed text. And the render read "no result plus a non-empty question" as a failed check — so for anyone who'd signed up without going through the landing search, the failure notice appeared on the first letter.

The fix is to remember what you pushed:

/** The last value we pushed UP through `onQueryChange`.
 *
 *  The caller hands it straight back as `need` — setup keeps the sentence in its own state so it
 *  survives stepping away from the step — and that echo used to re-enter the reset effect below as
 *  if it were a new question from outside. Every keystroke therefore wiped the results the user was
 *  reading and, for anyone who had signed up without picking anything first, replaced them with a
 *  failure notice for a request that had never been sent. Only a change we did not cause is a new
 *  question. */
const echoed = useRef(need)
const started = useRef(false)

useEffect(() => {
  // Only a question changed from OUTSIDE is a new question — see `echoed`.
  if (started.current && need === echoed.current) return
  started.current = true
  echoed.current = need
  setQuery(need)
  // …
}, [need, /* … */])
Enter fullscreen mode Exit fullscreen mode

Question 3: can this dependency change because of something this component did?

If yes, [dep] is not "when the outside world changes" — it's "when the outside world changes, or when I change". You need to be able to tell those apart, and only you can.

There's a second lesson buried in that bug report, and it's the one I'd actually put on a wall. The error message was rendered by inferring state from what was on screen: no result + non-empty query = failed. So the fix included storing it instead — idle | loading | ok | failed — and rendering the failure line only when a call actually came back empty. Derived state is a guess about the past. If a user can tell the difference between "hasn't asked yet" and "asked and it failed", your component has to be able to as well.


Bug 4: the dependency that wasn't there at all

The last one has an empty-ish array and still breaks, which is why it belongs here — the mistake is the same one wearing a different coat.

A guided tour is handed over the instant setup finishes:

close()            // navigates to /app
startLaunchTour(launch)   // …in the same tick
Enter fullscreen mode Exit fullscreen mode

drive (driver.js) filters out every step whose anchor is not visible at that moment. One tick before the shell paints, that's all of them. So the personalised welcome tour silently degraded to the two popovers that happen to point at nothing.

Nothing here is a dependency-array bug in the literal sense. It's the same misconception one level up: treating "the component ran" as "the thing it points at exists." An effect body running is a statement about React's tree, not about the DOM your library is about to query.

/** Wait for the workspace to actually be on screen.
 *
 *  Setup is a route now, so the tour is started from a page that is in the middle of being replaced
 *  by the shell — `close()` navigates, then hands the tour over in the same tick. `drive` filters out
 *  every step whose anchor is not visible AT THAT MOMENT, so without this the welcome tour arrived
 *  before the rail existed and quietly degraded to the two popovers that point at nothing.
 *
 *  The rail or the composer is enough: both belong to the shell, and either one being laid out means
 *  the workspace has painted. Bounded, because on a narrow layout the rail genuinely never appears
 *  and a tour that waits forever is worse than one that runs over what IS there. */
function waitForShell(): Promise<void> {
  const anchors = ['[data-tour="surfaces"]', '[data-tour="composer"]']
  return new Promise((resolve) => {
    const deadline = Date.now() + 4000
    const tick = () => {
      if (anchors.some(tourTargetVisible) || Date.now() > deadline) resolve()
      else setTimeout(tick, 120)
    }
    tick()
  })
}
Enter fullscreen mode Exit fullscreen mode

Note the deadline. On a narrow layout the rail genuinely never appears, and a tour that waits forever is worse than one that runs over what is there. Any wait-for-the-DOM helper without a bound is a hang you haven't met yet.

Question 4: does this effect assume something outside React has finished?

Mount is not paint. Navigation is not arrival. If you're handing an element to a library that queries the DOM, wait for the element — with a deadline.


The four questions

Print these next to the lint rule, not instead of it:

  1. Is any dependency here something I measured? Measured values never equal themselves.
  2. Does this effect react to this value, or merely read it? Reads go in a ref.
  3. Can this dependency change because of something this component did? Then you must be able to recognise your own echo.
  4. Does this assume something outside React has finished? Mount is not paint. Wait for the element, with a deadline.

The 60-second audit

Grep your own codebase for these four shapes. In our case each one took under a minute to confirm once I knew what I was looking at:

  • setX inside an effect that lists x. Every one of these is either a loop or a guard doing load-bearing work. Both are worth a second look.
  • Any dep that is an array, object or function from useMemo/useCallback. Ask what invalidates that memo. If the answer includes anything the user does continuously — scrolling, typing, resizing — you have bug 2.
  • A controlled child whose parent feeds the value back as a prop. Trace the round trip. If the child can't distinguish its own echo, you have bug 3.
  • querySelector in an effect, or any third-party library handed an anchor. Ask what guarantees it's there. "The effect ran" is not an answer.

What I'd stop saying

I don't think "just add it to the dependency array" is good advice any more, and I'd said it plenty. It's the right answer to the question the linter asks and the wrong answer often enough to the question your effect is actually asking.

The rule is a great smoke detector. It is not a design review. It cannot tell you that the value you added is measured, or churns, or is your own echo coming home — and in three of the four bugs above, doing exactly what it suggested is what shipped the bug.

The dependency array is not a list of what you read. It's a list of the reasons this should happen again. Write it as an answer to that question and most of this category disappears.

Top comments (0)