How to Fix the componentDidUpdate Infinite Loop Caused by Unconditional setState in React
Bug Severity: CRITICAL | UX/User Impact: FULL OUTAGE (browser tab freeze/crash) | Time to Fix: 5 mins
TL;DR
- What broke:
componentDidUpdatecallssetStatewith no condition, so every state update triggers another update, creating an infinite synchronous loop until the browser tab freezes or crashes. - How to fix it: Wrap every
setStatecall insidecomponentDidUpdatein aprevProps/prevStatecomparison guard so it only fires when a relevant value actually changed. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the guarded version generated instantly.
The Incident (What Does the Error Mean?)
The browser console will surface something like:
Warning: Maximum update depth exceeded. This can happen when a component
calls setState inside componentDidUpdate, but componentDidUpdate or
componentWillUpdate is not wrapped in a condition.
at MyComponent
React enforces a maximum re-render depth and throws this warning before it bails out — but by that point the main thread is already saturated. In production builds without the warning layer, the tab simply hangs. The component lifecycle is:
render()→ commit →componentDidUpdate()firessetState()called unconditionally → schedules new renderrender()→ commit →componentDidUpdate()fires again- Repeat until stack exhaustion or browser kill.
The Attack Vector / Blast Radius
This is not a subtle memory leak — it is an immediate, synchronous death spiral. The blast radius:
- CPU: A single looping component pegs one CPU core at 100%. On low-end mobile hardware this is a full device stall.
- Memory: Each render cycle allocates new fiber nodes, virtual DOM diffs, and closure scopes. Heap grows until GC cannot keep up.
- User session: The tab becomes unresponsive within milliseconds. There is no graceful degradation — the user sees a frozen UI and must kill the tab.
- Cascading failure: If this component is mounted in a high-traffic route (e.g., a dashboard shell), every user hitting that route gets a crashed tab simultaneously. Error monitoring (Sentry, Datadog RUM) will spike with
Maximum update depth exceededevents. - CI blind spot: Unit tests using shallow rendering often do not invoke
componentDidUpdate, meaning this ships silently through a green test suite.
How to Fix It (The Solution)
Basic Fix — prevState Guard
The minimal fix: compare the value you care about against its previous version before calling setState.
componentDidUpdate(prevProps, prevState) {
- this.setState({ formattedData: transform(this.props.data) });
+ if (prevProps.data !== this.props.data) {
+ this.setState({ formattedData: transform(this.props.data) });
+ }
}
Rule: Every setState inside componentDidUpdate must be gated by at least one prevProps.x !== this.props.x or prevState.x !== this.state.x check.
Enterprise Best Practice — Migrate to getDerivedStateFromProps or Hooks
For derived state (state computed from props), componentDidUpdate + setState is the wrong tool entirely. React 16.3+ provides purpose-built APIs that are structurally impossible to loop.
Option A: getDerivedStateFromProps (class component)
- componentDidUpdate(prevProps) {
- if (prevProps.data !== this.props.data) {
- this.setState({ formattedData: transform(this.props.data) });
- }
- }
+ static getDerivedStateFromProps(props, state) {
+ // Only called on every render; return null to make no state change.
+ if (props.data !== state._lastData) {
+ return {
+ formattedData: transform(props.data),
+ _lastData: props.data,
+ };
+ }
+ return null;
+ }
Option B: useMemo hook (functional component — preferred for new code)
- class MyComponent extends React.Component {
- state = { formattedData: [] };
- componentDidUpdate(prevProps) {
- if (prevProps.data !== this.props.data) {
- this.setState({ formattedData: transform(this.props.data) });
- }
- }
- render() { return <List items={this.state.formattedData} />; }
- }
+ function MyComponent({ data }) {
+ const formattedData = useMemo(() => transform(data), [data]);
+ return <List items={formattedData} />;
+ }
useMemo recomputes only when data reference changes. No lifecycle, no state, no loop risk.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your component trees, API endpoint strings, and internal data schemas. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing component into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.
Prevention in CI/CD
This class of bug should never reach a pull request review. Lock it out at the tooling layer:
1. ESLint — react/no-did-update-set-state (immediate, zero-config)
// .eslintrc.json
{
"plugins": ["react"],
"rules": {
"react/no-did-update-set-state": "error"
}
}
This rule flags any setState call inside componentDidUpdate, forcing the author to either add a guard or justify the exception. Wire it into your pre-commit hook via lint-staged.
2. Pre-commit hook (Husky + lint-staged)
// package.json
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": ["eslint --max-warnings=0"]
}
--max-warnings=0 ensures ESLint warnings are treated as hard failures — the commit is blocked.
3. CI pipeline gate (GitHub Actions example)
- name: Lint
run: npx eslint src/ --max-warnings=0
Place this step before unit tests. A lint failure is cheaper to catch than a test run.
4. React DevTools Profiler in staging
Enable the React DevTools Profiler in your staging environment and set a render count alert threshold. Any component exceeding 50 renders in a 5-second window should trigger a Slack alert. This catches loops that sneak past lint (e.g., dynamic setState calls constructed at runtime).
5. Long-term: enforce functional components in architecture review
Add a team ADR (Architecture Decision Record) mandating functional components with hooks for all new code. Class components with lifecycle methods are the primary habitat for this bug. Hooks make the infinite-loop pattern structurally harder to write.