← Recipes

阅读中文版本

Diagnose an Infinite Rerender Loop in React

Identify which component re-mounts or re-renders endlessly, trace the state update that fuels the loop, and break the cycle at the dependency.

~30 minfrontendreactdebugging

Task

Locate the loop owner, prove the update chain causing it, and stop the rerender without disabling correctness.

When to use this

A settings page warms the laptop and freezes the tab. The React devtools show one component rendering thousands of times, and no error explains why.

Prerequisites

  • React 18+ with React DevTools installed
  • A reproducible loop (page or interaction that triggers it)
  • Access to the component and its hooks

Steps

1. Name the loop owner with the profiler.

echo "open React DevTools > Profiler > record 2s > inspect flame chart; the widest repeated frame is the loop owner"

Expected output: The profiler shows one component such as SettingsPanel rendering 3,000+ times in two seconds, versus its neighbors rendering once.

2. Find the update that fires every render.

// suspect pattern #1: object/array identity in deps
useEffect(() => {
  setFilter({ ...filter, ready: true });      // new object every time
}, [filter]);                                // filter changed -> loop

// suspect pattern #2: unstable function from parent
function Parent() {
  const onTick = () => setTime(time + 1);     // new fn each render
  return <Child onTick={onTick} />;           // memo(Child) never memoizes
}

// suspect pattern #3: derived state stored instead of computed
const [total, setTotal] = useState(0);
useEffect(() => setTotal(items.reduce((a, i) => a + i.price, 0)), [items]);

Expected output: The three canonical loop shapes print; matching your code to one of them localizes the fix to a dependency, a memo, or a derived-state refactor.

3. Instrument the update to prove the chain.

useEffect(() => {
  console.count('[loop] filter effect fired');
  console.trace('[loop] updater');
}, [filter]);

Expected output: The console shows the effect firing hundreds of times with a stack trace naming the setState call that re-triggers it, converting suspicion into evidence.

4. Break the cycle at its actual joint.

// fix #1: update only when the value actually changes
useEffect(() => {
  setFilter((f) => (f.ready ? f : { ...f, ready: true }));
}, []);  // run once; guard inside the updater

// fix #2: stabilize the function identity
const onTick = useCallback(() => setTime((t) => t + 1), []);

// fix #3: compute, do not store
const total = useMemo(
  () => items.reduce((a, i) => a + i.price, 0),
  [items]
);

Expected output: The rerender count collapses to the intended one-per-interaction, and the profiler trace shows a flat line instead of a repeating tower.

5. Verify with the profiler and a render counter.

let renders = 0;
function SettingsPanel(props) {
  renders++;
  if (typeof window !== 'undefined') {
    window.__settingsRenders = renders;
  }
  // ... component body
}

Expected output: Loading the page and reading window.__settingsRenders shows a small stable number (1-3) instead of climbing every second; the counter is removed before merge.

Troubleshooting

Problem

Loop involves a context provider

Solution

Every context value change rerenders all consumers; memoize the value object with useMemo over its true dependencies, or split the context into changing and stable parts.

Problem

Loop only under Strict Mode double-render

Solution

That is Strict Mode surfacing a real impurity that production single-render hides; fix the impurity (side effect during render) rather than removing Strict Mode.

Problem

Loop is in a library component

Solution

Wrap the boundary with React.memo and stable props to isolate it, and file an upstream issue; your last resort is keys to force remount, which trades the loop for state loss.

Limitations

  • Render counts are development-tool observations; verify the fix on a production build, where dev overheads can exaggerate or mask patterns.
  • Breaking loops with memo everywhere adds complexity debt; prefer dependency and identity fixes over blanket memoization.

A loop is a setState that schedules itself

Infinite rerender is always one shape: a render path calls a state setter whose result re-triggers the same path. The profiler names the owner, console.trace proves the chain, and the fix lands on the exact joint: an unstable dependency, an unstable identity, or derived state that should never have been state.

the loop, named:
  SettingsPanel renders: 3,412 in 2s
  [loop] filter effect fired: 3,412
  [loop] updater trace:
    at SettingsPanel (SettingsPanel.tsx:41)
    -> setFilter({...filter, ready: true})
    -> filter identity changed
    -> effect deps [filter] fired again

after the guard-in-updater fix:
  renders: 2
  effect fired: 1

Identity, not value, is usually the bug

A useEffect that depends on an object or array re-fires every time the parent renders, because the literal creates a new identity each pass. The effect then sets state with another fresh object, and the loop is closed. Depending on primitives, or guarding inside the updater, breaks the cycle without weakening correctness.

Derived state should be computed, never stored

Every stored copy of a derivable value is a synchronization bug waiting for its moment: an effect syncs total from items, an interaction changes items mid-sync, and the two updates chase each other. useMemo computes the same value with no state, no effect, and no possible loop.