← Debugging Guides

阅读中文版本

React Infinite Render Loop — useState + useEffect Without Dependencies

Diagnose and fix infinite render loops caused by calling setState unconditionally inside useEffect without a dependency array.

intermediatereacthooksdebugginguseEffectperformance

Symptoms

  • Browser tab freezes or becomes unresponsive after navigating to a specific page
  • Console shows error: 'Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside useEffect or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.'
  • CPU usage spikes to 100% in browser developer tools performance tab
  • React DevTools profiler shows hundreds of component renders within seconds

Possible causes

  • useEffect without a dependency array that calls setState on every render
  • State update triggers re-render, which triggers the effect, which updates state again
  • Incorrect useEffect dependency array: updating a state that is also in the dependency array
  • Object or array literal in useEffect dependency array (reference instability causes re-run every render)
  • useCallback/useMemo returning a new reference on every render, used as a useEffect dependency

Diagnosis steps

Open React DevTools

F12 → React DevTools → Profiler tab → click record button

Interpretation

Record while navigating to the affected page. If you see hundreds of renders within 1-2 seconds, you have an infinite loop. The Profiler flamegraph will show the same component rendering repeatedly.

Inspect the error stack trace

Look at the console error and expand the call stack

Interpretation

The stack trace typically points to a useEffect or setState call. Look for patterns like 'at ComponentName (your-file.tsx:42)'. The line number inside your component's useEffect is the culprit.

Add a console.count or breakpoint

Interpretation

Add `console.count('render')` at the top of the component body and `console.count('effect')` inside each useEffect. Open the console — if the effect count climbs continuously, that effect has the bug.

Review useEffect dependencies

Interpretation

For each useEffect that calls setState, verify its dependency array. If it's missing (undefined), the effect runs after every render. If it includes a state that the effect sets, the loop is self-sustaining.

Root cause

A useEffect hook calls a state setter without the proper dependency array, creating an infinite re-render cycle. The classic pattern is: useEffect(() => { setCount(count + 1); }); — no dependency array means the effect runs after every render, which updates state, which re-renders, which runs the effect again.

Fix

Add the correct dependency array. If the effect should run only once on mount, use []. If it depends on `count` to increment it, use a callback form: setCount(c => c + 1) and include proper termination conditions. For derived state, use useMemo instead. For initial state that depends on props in useState: use useState(() => computeInitial(props)) to avoid recomputation.

Risk note

If the loop is in a critical path (authentication, payment, navigation), the browser may become unresponsive before you can fix it. Use the browser's 'kill page' button or devtools 'pause' to break the loop.

Verify the fix

After applying the fix, reload the page. The React DevTools profiler should show the component rendering only once or a bounded number of times (1-3), and the 'Maximum update depth exceeded' error must disappear. CPU usage should return to normal.

Symptom: Maximum Update Depth Exceeded

When a React component re-renders in an uncontrolled loop, you’ll see this error:

Warning: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside useEffect or componentDidUpdate.
React limits the number of nested updates to prevent infinite loops.

In React 18+, this is capped at ~50 updates before React aborts. In React 17 and earlier, the browser tab may freeze.

Root Cause Analysis

The most common pattern:

function BadCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1); // ❌ Runs after every render → triggers another render
  }); // ❌ No dependency array = runs after EVERY render

  return <div>{count}</div>;
}

What happens:

  1. First render: count=0
  2. useEffect runs: setCount(1) → schedules re-render
  3. Second render: count=1
  4. useEffect runs again: setCount(2) → schedules re-render
  5. …repeat until React gives up (>50 updates)
  6. Error thrown, component state may be corrupted

Step-by-Step Diagnosis

Step 1: Confirm the Loop with React DevTools Profiler

Open Chrome DevTools → Components tab → Settings → check “Highlight updates when components render”. Open the affected page. If a component flashes continuously, that’s the looping component.

Step 2: Find the Offending useEffect

Add a temporary counter:

useEffect(() => {
  console.log('effect ran, count:', count);
  setCount(count + 1);
});

Open the console. If the log appears rapidly and repeatedly without clicks, the loop is confirmed.

Step 3: Check Dependency Arrays

Review every useEffect that calls setState:

// ❌ Problematic (no deps = runs every render)
useEffect(() => { setUser(filteredUser); });

// ❌ Problematic (state in deps that the effect sets)
useEffect(() => { setCount(count + 1); }, [count]);

// ✅ Effect runs once on mount
useEffect(() => { setCount(initialValue); }, []);

// ✅ Use callback form for derived updates
useCount((prev) => prev + 1);

// ✅ Guard with condition
useEffect(() => {
  if (count < 10) setCount(count + 1);
}, [count]);

The Fix

Pattern A: Initialize State From Props

// ❌ Runs on every render, overwriting user changes
useEffect(() => {
  setLocalName(props.name);
});
// OR:
const [localName, setLocalName] = useState(props.name); // Better: init in useState

// ✅ Resets only when identity_ref_key changes
useEffect(() => {
  setLocalName(props.name);
}, [props.id]); // only reset when the user identity changes, not every render

Pattern B: Derived State

// ❌ Duplicate state → sync effect
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
useEffect(() => { setItemCount(items.length); }, [items]);

// ✅ Compute derived value directly (no effect, no sync needed)
const itemCount = items.length;

Pattern C: Stable References for Object/Array Deps

// ❌ New object reference every render → infinite loop
useEffect(() => {
  fetch(`/api/data?filter=${JSON.stringify(filter)}`);
}, [filter]); // filter is a new {} every render

// ✅ Memoize or use primitive deps
const filterKey = useMemo(() => `${filter.type}-${filter.status}`, [filter.type, filter.status]);
useEffect(() => {
  fetch(`/api/data?filter=${filterKey}`);
}, [filterKey]);

Verification

After the fix:

  1. Navigate to the affected page
  2. Open React DevTools — components should render 1-3 times on initial load
  3. CPU usage stays under 5% at idle
  4. No console errors

Prevention Checklist

  • Every useEffect that updates state MUST have a dependency array
  • State that derives from other state should use useMemo, not a sync effect
  • Props-to-state sync effects should include a semantic guard (id, version), not the whole prop
  • Use the ESLint plugin eslint-plugin-react-hooks to catch missing deps at build time