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.
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 buttonInterpretation
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 stackInterpretation
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:
- First render: count=0
- useEffect runs: setCount(1) → schedules re-render
- Second render: count=1
- useEffect runs again: setCount(2) → schedules re-render
- …repeat until React gives up (>50 updates)
- 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:
- Navigate to the affected page
- Open React DevTools — components should render 1-3 times on initial load
- CPU usage stays under 5% at idle
- 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-hooksto catch missing deps at build time