On This Page
Uncaught Error: Too many re-renders
Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
at renderWithHooksAgain (react-dom_client.js:4444:53)
at renderWithHooks (react-dom_client.js:4402:62)
at updateFunctionComponent (react-dom_client.js:5775:16)
at beginWork (react-dom_client.js:6391:20)
at runWithFiberInDEV (react-dom_client.js:944:66)
at performUnitOfWork (react-dom_client.js:9148:92)
at workLoopSync (react-dom_client.js:9044:37)
at renderRootSync (react-dom_client.js:9028:6)That's the real output from a React 19.3.0 dev server, not a paraphrase. Right below it, React logs a second line to the console:
An error occurred in the <Counter> component. Consider adding an error boundary to your tree
to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.If that's your tab right now, jump to "Four fixes, ranked by how often you need them." If you want to know why React stops at exactly this point and not, say, one render later, keep going. There's a hard number buried in React's source that explains it, and it hasn't moved in years.
I found this one on a staging deploy, not on my own machine. A teammate's PR added a small "trend" label under a counter, up or down or same, and it worked fine in their local testing because they'd clicked the button exactly once before opening the PR. Two clicks on staging and the tab locked solid. Nothing in the diff looked wrong at a glance. That's usually a sign you're looking at the wrong file.
Break it yourself in under a minute
Scaffold a throwaway project:
bash
npm create vite@latest repro -- --template react-ts
cd repro
npm installThat installs React 19.3.0 as of this week. Check what you actually got with npm ls react react-dom, because the caret range in Vite's template (^19.2.8) will happily pull a newer patch.
Replace src/App.tsx:
tsx
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
}
// Bug: this calls handleClick() during render and uses
// whatever it returns (undefined) as the click handler.
return <button onClick={handleClick()}>Clicked {count} times</button>;
}
export default function App() {
return (
<div>
<h1>Repro</h1>
<Counter />
</div>
);
}Run npm run dev, open it, and the error fires before you even click anything, on the very first render. No StrictMode double-mount required, no effect, no network round trip. It's not a dev-only quirk either. Build it (npm run build && npm run preview) and you get the production form of the same throw:
Uncaught Error: Minified React error #301; visit https://react.dev/errors/301 for the full
message or use the non-minified dev environment for full errors and additional helpful warnings.Both of those are pasted straight from a terminal, not typed from memory. React genuinely keeps #301 mapped to this exact string, and the Grafana team has a public GitHub issue where they hit that exact code in production, which is a good reminder that this bug ships all the time, not just in tutorials.
One more thing worth confirming before you go further: this has nothing to do with StrictMode. I pulled <StrictMode> out of main.tsx entirely and re-ran the repro. Identical error, identical stack. If someone tells you StrictMode is "causing" a re-render loop, they've mixed up an intentional double-invoke in dev with a genuine infinite loop. The loop is real with or without it.
Does React 18 throw this any differently than 19?
Short answer: no, not in any way that changes what you need to do. Longer answer, because I went and checked the actual bundles instead of assuming:
| React | Retry loop location | RE_RENDER_LIMIT | Message |
|---|---|---|---|
| 18.3.1 | inline inside renderWithHooks | 25 | identical text |
| 19.3.0 | split into a separate renderWithHooksAgain function called from renderWithHooks | 25 | identical text |
I grepped both react-dom@18.3.1 and react-dom@19.3.0's development bundles directly. RE_RENDER_LIMIT = 25 is there in both, byte-for-byte the same throw string in both. The only difference is cosmetic: React 19 pulled the retry loop out into its own named function, so your stack trace grows one frame longer than it would on 18. Don't grep for renderWithHooksAgain and expect it to be there forever. Internal function names aren't a public API, and Meta can rename them in the next patch without a deprecation notice. The 25-pass ceiling is the part that's been stable.
Class components mostly sidestep this specific error, because there's no hooks dispatcher to run the retry loop against. If a class component loops on setState inside componentWillUpdate or componentDidUpdate, you'll hit React's other render-loop guard instead: Maximum update depth exceeded, error #185, which caps at a different number (50) and counts nested updates across renders rather than retries within one. That's a related but genuinely separate bug with a separate article's worth of causes; if your error says "Maximum update depth" instead of "Too many re-renders," you're in that one, not this one.
I didn't test this under a server renderer. If you hit the equivalent loop during renderToString or renderToPipeableStream in Next.js or Remix, expect it to blow up the server-side request instead of a browser tab, since it's the same hooks dispatcher doing the rendering. I haven't confirmed the exact wording server-side, though, so take that as a reasonable guess rather than a verified fact.
The one-line cause: setting state during your own render
Every version of this bug boils down to the same mechanical fact: the component currently being rendered called the setter for its own state, without a condition that eventually stops being true. React doesn't wait for the browser to repaint or for the scheduler to hand back control. It just calls your component function again, right there, synchronously, as many times as it takes. Up to 25 times. Then it gives up and throws.
Inside renderWithHooksAgain
Here's the part nobody puts in a Stack Overflow answer, because it means reading the source instead of the docs.
When your component calls a hook's setter, React first asks a very specific question: is the fiber that owns this piece of state, checked via isRenderPhaseUpdate(fiber), the same fiber currently sitting in currentlyRenderingFiber? If you call your own setter while your own component function is on the stack, the answer is yes, and React takes a different path than a normal state update. Instead of scheduling work for later, it flips two flags, didScheduleRenderPhaseUpdate and didScheduleRenderPhaseUpdateDuringThisPass, and lets the current render finish.
Back in renderWithHooksAgain, there's a do...while loop built around exactly that flag:
do {
didScheduleRenderPhaseUpdateDuringThisPass = false;
if (numberOfReRenders >= RE_RENDER_LIMIT) {
throw Error("Too many re-renders...");
}
numberOfReRenders += 1;
children = Component(props, secondArg); // calls your function again
} while (didScheduleRenderPhaseUpdateDuringThisPass);That's not pseudocode. It's the shape of the actual compiled source, variable names included. Your component function gets invoked again, immediately, in the same synchronous pass, and if it sets state unconditionally every time, the flag gets set again every time, and the loop just keeps going until it hits 25 and throws. The browser never gets a chance to paint in between. Nothing else on the page runs. It's the fastest possible infinite loop React can produce, which is exactly why the tab locks up instead of slowly degrading.
Think of it like a spreadsheet with a circular formula: cell A references cell B, which references cell A, so the engine keeps recalculating until it hits its iteration cap and refuses to go further. React's cap just happens to be 25, and it throws instead of silently giving up.
That fiber-identity check also explains something that trips people up: if you update a different component's state while rendering, a parent setting a child's state, or vice versa, isRenderPhaseUpdate returns false, because the fibers don't match. You don't get this error at all. You get a different warning: Cannot update a component (X) while rendering a different component (Y). Same general neighborhood of bug, updating state during render, but a different code path, a different message, and a different article's worth of causes. Worth knowing so you search for the right thing.
How I actually found mine
I want to walk through the wrong turns first, because your first two guesses are probably going to be mine.
Dead end one: I assumed it was an effect dependency problem. "Too many re-renders" sounds enough like "infinite useEffect loop" that I spent a good twenty minutes stripping dependency arrays down, wrapping the click handler in useCallback, and checking exhaustive-deps output. None of it changed anything, because the bug wasn't in an effect at all. It was firing during the render phase, before any effect had a chance to run. Once I actually read the stack trace instead of skimming it, renderWithHooksAgain and beginWork were the tell: those are render-phase frames. If your stack has commitHookEffectListMount or similar commit-phase frames instead, you're chasing a different bug, likely #185, not this one.
Dead end two: I suspected StrictMode. The trend-label component was added the same week someone else enabled <StrictMode> more broadly, and double-invocation felt like a plausible enough coincidence to check. I commented it out. Error, identical, first render. Confirmed above with a fresh repro too. StrictMode's double-mount changes how many times you see warnings, not whether a genuine infinite loop resolves itself.
Once both of those were off the table, here's what actually worked, in order:
- Read the component name in the second console line, not just the stack.
An error occurred in the <Counter> componenttells you exactly which fiber React was retrying. If it names a component you didn't touch, check who's rendering it and passing it props. The bug might be one level up. - Comment out JSX from the leaves inward until the error stops. This narrows down which component in the tree is looping, faster than reading every file.
- Grep that one component's body for a setter call that isn't inside a function. Anything that reads
setSomething(...)sitting directly in the component body, outsideuseEffect, outside an event handler, and outside anifthat can eventually become false, is a candidate. - Check every prop passed as
onX={fn()}instead ofonX={fn}. This is the single most common variant and it's a one-character difference from correct code.
The tell that actually cracked it for me: the stack trace is identical no matter which of these four causes produced it. React doesn't tell you why you're re-rendering, only that you are, 25 times, synchronously. So you can't diagnose this from the trace — you have to go read the component the trace names, top to bottom, for anything that sets its own state without a guard.
Ranked by how often each one is actually the culprit, in my experience and matching what shows up across the bug trackers and Q&A threads I checked:
- Calling the handler eagerly in JSX.
onClick={handleClick()},onChange={validate(value)}, any prop that should hold a function reference but holds a function call instead. This was mine, and it's the one react.dev's ownuseStatereference page calls out by name. - A bare
setStatecall sitting in the component body, not wrapped in a handler or effect. Often left behind by a refactor that moved logic "temporarily" out of auseEffectand never moved it back. - The legitimate "adjust state during render" pattern, missing its guard. React's docs explicitly allow calling a setter during render to store information from a previous render, but only inside a condition like
if (prevProp !== prop). Drop the condition and you've turned a documented pattern into this bug. - The same missing guard, one layer down. Inside a custom hook, a context provider deriving its value from props on every render, or a small wrapper around a third-party input component. You won't even see a
setStatecall in the component that's crashing, because it's hiding inside something you imported.
Four fixes, ranked by how often you need them
Fix 1: pass the reference, don't call it.
diff
- return <button onClick={handleClick()}>Clicked {count} times</button>;
+ return <button onClick={handleClick}>Clicked {count} times</button>;If you need to pass an argument, wrap it: onClick={() => handleClick(id)}. This fixes cause #1 outright and it's almost always the fix people need.
Fix 2: move the stray setState into a handler or an effect.
diff
function Counter() {
const [count, setCount] = useState(0);
- setCount(count + 1);
return <div>{count}</div>;
}If that line needs to run once, on mount, it belongs in a useEffect with an empty dependency array. If it needs to run in response to a user action, it belongs in a handler. It almost never belongs bare in the function body.
Fix 3: put the guard back on the derived-state pattern.
diff
function CounterLabel({ count }: { count: number }) {
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState('same');
- setPrevCount(count);
- setTrend(count > prevCount ? 'up' : 'down');
+ if (prevCount !== count) {
+ setPrevCount(count);
+ setTrend(count > prevCount ? 'up' : 'down');
+ }
return <div>{count} ({trend})</div>;
}I tested exactly this component, guarded, clicking a real button twice: it renders 2 (up) and throws nothing. The guard is what makes the pattern legal instead of a loop.
Fix 4: find the setState hiding in your abstraction. Same guard, just applied inside whatever hook, provider, or wrapper is actually calling the setter. There's no shortcut here beyond reading the hook's source.
What I wouldn't do: wrap the crashing component in an error boundary and call it fixed. An error boundary stops the whole app from going white, which is worth having regardless, but it doesn't touch the underlying loop. The component still can't render; it just fails more gracefully now. I've seen this exact move ship, with the boundary's fallback UI quietly hiding a broken feature for months until someone finally clicked into the right state.
Write the component so this can't happen
The strongest fix for the trend-label example isn't fix 3. It's not storing trend in state at all:
tsx
function CounterLabel({ count }: { count: number }) {
const prevCountRef = useRef(count);
const trend = count === prevCountRef.current
? 'same'
: count > prevCountRef.current ? 'up' : 'down';
useEffect(() => {
prevCountRef.current = count;
});
return <div>{count} ({trend})</div>;
}trend is computed fresh every render from a value React never triggers a re-render for (useRef), and the ref only gets written after render commits, in an effect. There's no setState-during-render at all, guarded or not, so there's no version of this bug to write.
I'll say the unpopular part out loud: I don't love React's own "adjust state during render" pattern, guard and all. It's documented, it's legal, and it works, but every real codebase I've seen use it eventually loses the guard to a refactor, because it looks exactly like a normal setState call sitting a few lines above a return. If you can compute a value instead of storing it, do that, even if it costs a few extra cycles of render work. A derived value can't desync from the thing it's derived from. Stored state can.
For genuinely interdependent state, several pieces of state that only make sense updated together, reach for useReducer instead of multiple useState calls tied together with conditionals. A reducer forces every transition through one function, which makes "does this transition ever fail to terminate" a much easier question to answer by reading it than several scattered setters ever will.
Let set-state-in-render catch this before your users do
Lint for it directly. eslint-plugin-react-hooks v6 and later ships a compiler-powered set-state-in-render rule that flags unconditional setState calls during render: exactly this bug, caught before you ever run the app.
js
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
export default defineConfig([
reactHooks.configs.flat['recommended-latest'],
]);The plain recommended preset gets you rules-of-hooks and exhaustive-deps; recommended-latest is what currently opts you into the compiler-powered rules including set-state-in-render. I'm running eslint-plugin-react-hooks@7.1.1, the current published version, and that's the config shape its own README documents for flat config.
Let TypeScript catch cause #1 for you. This part surprised me enough that I checked it twice: if you're on .tsx and you write onClick={handleClick()} where handleClick returns void, tsc refuses to compile:
error TS2322: Type 'void' is not assignable to type 'MouseEventHandler<HTMLButtonElement> | undefined'.That's a real compiler error from a real tsc -b run against this exact repro, not a hypothetical. It only catches cause #1. TypeScript has no opinion on whether your bare setCount call in the render body is a good idea, but it's a free net for the single most common cause, and it's one more reason to default to TypeScript for anything with event handlers.
Test for it, not around it. A component test that renders the tree and asserts on output won't reliably catch a render-phase throw if your test setup swallows console errors or wraps everything in a blanket try/catch. Use React Testing Library's render without suppressing errors, and let a thrown "Too many re-renders" fail the test loudly. That's the correct outcome, not a false negative to work around.
Watch for the code in production. If you're on Sentry, Datadog, or similar, alert on Minified React error #301 specifically, separately from #185. They point at different bugs with different fixes, and a shared "React error" alert bucket makes you re-diagnose from scratch every time.
Related, if you're chasing this family of bug elsewhere: Too many re-renders sits right next to Maximum update depth exceeded and Cannot update a component while rendering a different component. All three come from setting state during render, but each one is a different fiber-identity check with a different fix.
What sticks
Twenty-five. That's the actual number, read straight from React's own source in both 18 and 19, and it hasn't moved. If your component calls its own setter unconditionally during render, you get exactly 25 attempts before React throws, synchronously, before the browser paints anything.
The stack trace can't tell you which of the four causes you've got — it looks the same for all of them. Read the component the trace names, not the trace itself.
onClick={fn()} versus onClick={fn} is still the single most common way into this bug, and TypeScript will catch that one specific case for you if you let it.
The "adjust state during render" pattern is legal, documented, and still worth avoiding when you can compute the value instead of storing it. A derived value can't drift out of sync with its guard condition, because it doesn't have one.
StrictMode isn't your problem here. If removing it "fixes" a render loop, you didn't fix anything. You made it harder to notice.
CODELZ Newsletter
Join the newsletter to receive the latest updates in your inbox.