How to Fix 'Too Many Re-renders' in React: Stopping Infinite Loop Crashes
Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 5–15 mins
TL;DR
- What broke: A
setStatecall (or equivalent) is executing unconditionally during every render, causing React to loop until it hard-crashes the component. - How to fix it: Move state mutations into
useEffectwith a correct dependency array, or ensure event handlers are referenced not invoked in JSX. - Use our Client-Side Sandbox below to auto-refactor this — paste your component and get a corrected diff instantly.
The Incident (What Does the Error Mean?)
Raw error output:
Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
at renderWithHooks (react-dom.development.js:14985)
at mountIndeterminateComponent (react-dom.development.js:17811)
at beginWork (react-dom.development.js:19049)
React enforces a hard render limit (currently 25 renders per synchronous cycle). When that ceiling is hit, React throws synchronously and unmounts the entire subtree — not just the offending component. Every child component, context consumer, and portal underneath it is torn down. In production, users see a blank screen or a broken UI with no recovery path unless you have an ErrorBoundary.
The Attack Vector / Blast Radius
This is not a subtle degradation — it is an immediate, total component failure. The blast radius:
- User-facing: Full white screen or broken render if no
ErrorBoundaryis present. - Cascading state corruption: If the looping component owns shared context or a Zustand/Redux slice, in-flight mutations may leave global state in a partial, inconsistent state before the crash.
- CI invisibility: This error often does not surface in unit tests unless your test renderer runs inside
act()with strict mode. It ships to production silently. - Memory spike before crash: 25 rapid synchronous renders with closure allocations cause a measurable heap spike. On low-memory mobile devices, this can trigger the browser's tab killer before React's own circuit breaker fires.
The four most common triggers:
| Pattern | Why It Loops |
|---|---|
setState(value) called in render body |
Every render triggers state change → triggers render |
useEffect(() => setState(...)) with no dep array |
Runs after every render, sets state, triggers render |
<button onClick={handler()}> |
handler() executes at render time, not on click |
useEffect dep array contains a new object/array literal |
New reference every render → effect re-fires → setState → render |
How to Fix It
Basic Fix — The Most Common Offender: setState in Render Body
function UserProfile({ userId }) {
- const [data, setData] = useState(null);
- setData(fetchUserSync(userId)); // ❌ called directly in render
+ const [data, setData] = useState(null);
+
+ useEffect(() => {
+ setData(fetchUserSync(userId)); // ✅ runs after render, not during
+ }, [userId]); // ✅ only re-runs when userId changes
return <div>{data?.name}</div>;
}
Basic Fix — Invoked Handler vs. Referenced Handler
function SubmitButton({ onSubmit }) {
return (
- <button onClick={onSubmit()}>Submit</button> {/* ❌ invokes on render */}
+ <button onClick={onSubmit}>Submit</button> {/* ✅ references, invokes on click */}
);
}
Enterprise Best Practice — Unstable Object References in useEffect Deps
This pattern is the hardest to spot in code review and the most common in enterprise codebases using API response objects as dependencies.
function Dashboard({ filters }) {
const [results, setResults] = useState([]);
- useEffect(() => {
- fetchData(filters).then(setResults);
- }, [filters]); // ❌ if filters is a new object on every parent render, this loops
+ // Stabilize the reference. Option 1: useMemo in parent.
+ // Option 2: depend on primitive values derived from filters.
+ const filterKey = JSON.stringify(filters); // stable primitive
+
+ useEffect(() => {
+ fetchData(filters).then(setResults);
+ }, [filterKey]); // ✅ only changes when filter content actually changes
}
Enterprise Best Practice — useReducer for Complex State Transitions
When multiple useState calls interact, replace them with useReducer. This eliminates the class of bugs where setting state A triggers a render that sets state B.
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [data, setData] = useState(null);
-
- // scattered setState calls across multiple useEffects create race conditions
+ const initialState = { loading: false, error: null, data: null };
+
+ function reducer(state, action) {
+ switch (action.type) {
+ case 'FETCH_START': return { ...state, loading: true, error: null };
+ case 'FETCH_SUCCESS': return { loading: false, error: null, data: action.payload };
+ case 'FETCH_ERROR': return { loading: false, error: action.error, data: null };
+ default: return state;
+ }
+ }
+
+ const [state, dispatch] = useReducer(reducer, initialState);
+ // Single dispatch per transition. Zero risk of cascading setState loops.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config 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 is statically detectable. It should never reach a PR review.
1. ESLint: react-hooks/exhaustive-deps (Non-Negotiable)
This rule flags missing and incorrect dependency arrays at lint time.
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn" // Promote to 'error' in greenfield projects
}
}
Block merges in CI if lint fails:
# .github/workflows/ci.yml
- name: Lint
run: npx eslint src/ --max-warnings=0 # zero-tolerance for hook warnings
2. React Strict Mode in Development
<React.StrictMode> intentionally double-invokes render functions and effects in development to surface side effects hidden in render bodies. Enable it globally and never disable it for individual components to "fix" the warning — that is always a symptom, not a solution.
- root.render(<App />);
+ root.render(
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+ );
3. ErrorBoundary as a Blast Shield (Production Mandatory)
This does not prevent the loop but contains the blast radius to the subtree, keeping the rest of the application alive.
// Wrap every major route-level component
<ErrorBoundary fallback={<ErrorPage />}>
<DashboardPage />
</ErrorBoundary>
4. Component-Level Render Count Monitoring (Observability)
In production, instrument with why-did-you-render in staging builds or use React DevTools Profiler to catch components with abnormally high render counts before they hit the 25-render limit.
npm install @welldone-software/why-did-you-render --save-dev
// src/wdyr.js (import before React in index.js, staging only)
import React from 'react';
import whyDidYouRender from '@welldone-software/why-did-you-render';
if (process.env.REACT_APP_ENV === 'staging') {
whyDidYouRender(React, { trackAllPureComponents: true });
}