Too many re-renders: the fix that actually sticks

React react-hooks rerendering useState debugging javascript frontend-development
Bharath
Reading Progress

On This Page

The exact line React throws before the tab freezes

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
    at throwRenderPhaseUpdate (react-dom-client.development.js)
    at updateReducerImpl (react-dom-client.development.js)
    at updateReducer (react-dom-client.development.js)
    at updateState (react-dom-client.development.js)
    at Object.useState (react.development.js)
    at FilterPanel (FilterPanel.tsx:9:5)
    at renderWithHooksAgain (react-dom-client.development.js)
    at renderWithHooks (react-dom-client.development.js)

That's the development build. In production it collapses to this:

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.

Same error, same cause, same fix. #301 is the permanent code for this one. It hasn't moved since React introduced the decoder, and it won't, because the string itself has been stable since hooks shipped in 16.8. If you're seeing this on 17, 18, or 19, the wording underneath is identical. What changes across versions is only the stack trace filename (react-dom.development.js on 17/18, react-dom-client.development.js on 19+ after the client/server package split) and, from 19.1 onward, an owner stack under the component stack that tells you which component's JSX actually rendered the broken one. Everything else below applies the same way regardless of which one you're on.

I lost the better part of a day to a version of this on a product-filter sidebar (fourteen checkbox groups, one "expand if there's a lot to show" toggle), the afternoon before a demo to the merchandising team. The bug wasn't the four-line textbook example every article shows you. It was hiding behind a condition that looked like it should only fire once. It didn't. That gap between "looks guarded" and "is guarded" is the whole article.

Four lines that reproduce it every time

tsx

// App.tsx
import { useState } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  setCount(count + 1); // called unconditionally, every render
  return <div>{count}</div>;
}

Scaffold it and watch it happen:

bash

npm create vite@latest repro -- --template react-ts
cd repro
npm install
npm run dev

json

{
  "dependencies": {
    "react": "19.3.0",
    "react-dom": "19.3.0"
  },
  "devDependencies": {
    "vite": "8.3.0",
    "typescript": "7.0.2"
  }
}

Open the page and the console shows the error before anything paints. There's no flicker, no visible count climbing. The tab never gets a frame to show you. That absence of a visible render is a clue we'll come back to.

The four-liner above is the textbook case, and it's real, but it's rare in practice. Nobody ships setCount(count + 1) bare in a component body on purpose. The version that actually eats people's afternoons looks "protected":

tsx

// FilterPanel.tsx
import { useState } from 'react';

interface FilterPanelProps {
  categoryCount: number;
}

export function FilterPanel({ categoryCount }: FilterPanelProps) {
  const [expanded, setExpanded] = useState(false);

  if (categoryCount > 10) {
    setExpanded(true); // looks conditional. isn't, in the way that matters.
  }

  return (
    <button onClick={() => setExpanded(!expanded)}>
      {expanded ? 'Collapse' : 'Expand'} ({categoryCount})
    </button>
  );
}

categoryCount > 10 reads like a guard. It isn't a guard against this render calling setExpanded again. It's a guard against a completely different piece of data, one that never changes across the retry loop. Every single retry, categoryCount is still greater than 10, so setExpanded(true) fires again, every time, all the way to 25.

Triggers worth naming explicitly, because they change what you'll see: this throws identically in dev and in a production build (it's a real throw, not a console.error, so it survives minification and stops the app either way); it fires on first mount just as easily as after an interaction; it does not require StrictMode to happen, and turning StrictMode off will not fix it (more on that below).

Does this look different on React 17, 18, or 19?

VersionBehavior
16.8–17.xSame throw, same limit, same message. Stack frames say react-dom.development.js.
18.0–18.3Same. Automatic batching (18.0) doesn't change the render-phase retry mechanism. It batches commits, not this internal retry loop.
19.0Same throw. Stack frames move to react-dom-client.development.js after the react-dom/client vs react-dom/server split.
19.1+Same throw, plus an owner stack in the dev overlay naming the JSX call site that created the misbehaving element, genuinely useful because it can point at a parent you weren't even looking at.
19.2–19.3No change to this error. useEffectEvent and <Activity> (19.2) and View Transitions (19.3) don't touch the hooks dispatcher's retry loop.

Class components are the one place the behavior is genuinely different, and it's worth knowing if your codebase is mixed. Calling this.setState() unconditionally inside render() doesn't hit the 25-attempt hooks limit at all. It hits a different, older guard and produces a console.error warning instead of a thrown error: Cannot update during an existing state transition (such as within render or another component's constructor). Same mistake, same family of bug, a different code path entirely, because class components never went through the hooks dispatcher this counter lives in. If your stack trace mentions updateReducer or useState/useReducer, you're in hooks-land and this article is for you. If it says Cannot update during an existing state transition, that's a different mechanism. Still worth knowing the message exists, but the fix below is the same regardless.

Always the same shape underneath

Strip away the framing and it's one sentence: something in the function body (not an effect, not an event handler: the body itself, or a JSX expression evaluated as part of it) calls a state setter, or a dispatch from useReducer, that actually produces a new value, and nothing about your code makes that stop being true on the next attempt.

That's it. onClick={handleClick()} instead of onClick={handleClick} is one flavor: calling the function during the JSX evaluation instead of handing React a reference to call later. A setState sitting bare in the component body, guarded by a condition that never resolves, is another. A custom hook that syncs a prop into state without comparing against the previous value is a third, and it's the nastiest one because the setter call is hidden a layer down from the component you're staring at.

Inside renderWithHooksAgain, where the retry counter lives

Here's the part that actually explains why this throws instead of just... rendering forever and hanging the tab.

When your function component calls a state setter while React is in the middle of calling that same function, as opposed to later from an event handler or an effect, React can't just let the update sit in a queue for next time. The value you're computing right now, in this render, would already be stale. So React sets a flag, didScheduleRenderPhaseUpdateDuringThisPass, finishes evaluating your function once, throws the result away, and calls your function again immediately, synchronously, before returning control to anything else. Not the browser, not an effect. Nothing gets a turn in between.

It tracks how many times it's done this with a counter, numberOfReRenders, inside renderWithHooksAgain in ReactFiberHooks.js. The ceiling is a constant:

js

const RE_RENDER_LIMIT = 25;

Hit it, and React gives up rather than let your tab lock the main thread forever:

js

if (numberOfReRenders >= RE_RENDER_LIMIT) {
  throw new Error(
    'Too many re-renders. React limits the number of renders to prevent ' +
      'an infinite loop.',
  );
}

Twenty-five is arbitrary in the sense that any number would do, and it's not arbitrary in the sense that it's tuned to be way more than any legitimate render-phase update pattern needs, which, not coincidentally, is the reason the guarded "adjust state during render" pattern (the one in the React docs for resetting state when a prop changes) never comes near this error. It converges in exactly one extra pass: the condition that trips the setter becomes false the instant the setter runs, so numberOfReRenders goes from 0 to 1 and stops. Your bug's condition doesn't do that. That's the entire difference between "safe render-phase update" and "this error": not whether you're allowed to call a setter during render (you are, React explicitly supports it), but whether calling it actually changes the thing your condition is checking.

Worth being precise about the neighbor error here, because people conflate them constantly. Maximum update depth exceeded (Minified React error #185) comes from a different counter: nestedUpdateCount, capped by NESTED_UPDATE_LIMIT = 50 in ReactFiberWorkLoop.js, that tracks updates across full, separate render-and-commit cycles on the same root, the kind you get from an effect or componentDidUpdate calling setState every time it fires. This error's counter never leaves a single render attempt; it never commits, never fires an effect, never lets the browser paint. If your loop involves useEffect at all, you want the #185 article, not this one. They share a family resemblance and nothing else.

Two wrong turns before I found the real one

First theory: a stale dependency in a useEffect somewhere upstream, refiring on every render and setting state each time. Plausible, because that's the other infinite-loop error and my brain jumped there out of habit. I put a console.log(JSON.stringify(dep)) above the effect and watched it for ten renders. Identical every time. Dead end, and the tell I should have caught sooner was that the crash happened before the console ever printed anything from an effect at all. Effects run after commit. There was no commit.

Second theory: an inline object passed as a prop to a memoized child, defeating React.memo's shallow comparison and causing a comparison-driven loop. I wrapped the prop in useMemo, redeployed the repro, watched it throw at the exact same line, on the exact same render count. Memoizing a prop changes whether a child re-renders when its parent already has. It does nothing about a setter your own component's body calls on itself, unconditionally, every time it runs. I don't love how long that one took me, because in hindsight the stack trace had already told me: every frame pointed at FilterPanel, never at its parent, never at a child.

The tell that actually cracked it: I put a raw console.log('render', ++renderCount) at the very top of the component, above the hook calls, and reloaded. It printed 1 through 25 in a single synchronous burst: no paint between them, no gap in the timestamps, same exact line of the stack under every one. An effect-driven loop paints between iterations, because each one is a full commit; you'd see the DOM twitch, or at least seek DevTools' Highlight Updates flashing repeatedly. This didn't twitch. It never got to commit once. That's what "render phase" means in practice, not just in the docs: it happens before the DOM exists for this pass, so there's nothing to see except the count and then the throw.

Ranked by how often each one is actually the culprit, in my experience and in what shows up across the bug trackers: calling a handler instead of referencing it in JSX is the single most common one, especially in list rendering (items.map(i => <Row onClick={handleSelect(i.id)} />) is a frequent variant of the same mistake). A bare, unconditional setter call in the component body, no JSX involved at all, is second, and it's the one that ate my day, because a guard that references the wrong variable reads exactly like a correct guard until you trace through what actually changes between attempts. A setter hidden inside a custom hook that syncs an external value into state without an equality check is third, and the hardest to spot precisely because the offending line isn't in the component you're debugging. Unconditional dispatch calls from useReducer are the same bug wearing a different hook, and rarer only because useReducer is used less.

Four fixes, ranked by how often you need them

Move the update into the place it belongs. If the state change should happen in response to a click, put the setter in the onClick, not in the render body or the JSX expression:

diff

- <button onClick={setCount(count + 1)}>{count}</button>
+ <button onClick={() => setCount(count + 1)}>{count}</button>

Stop storing it as state at all. Most render-body setState bugs exist because someone's syncing a value that should just be computed fresh every render:

diff

- const [expanded, setExpanded] = useState(false);
- if (categoryCount > 10) {
-   setExpanded(true);
- }
+ const expanded = categoryCount > 10;

No state, no setter, no loop, because there's nothing left to retry.

If it genuinely needs to be state that resets on a prop change, guard it against the thing that should make it stop, not against unrelated data:

tsx

function FilterPanel({ categoryCount }: FilterPanelProps) {
  const [expanded, setExpanded] = useState(false);
  const [prevCategoryCount, setPrevCategoryCount] = useState(categoryCount);

  if (categoryCount !== prevCategoryCount) {
    setPrevCategoryCount(categoryCount);
    setExpanded(categoryCount > 10);
  }

  return (
    <button onClick={() => setExpanded(!expanded)}>
      {expanded ? 'Collapse' : 'Expand'} ({categoryCount})
    </button>
  );
}

This is the pattern React's own docs endorse for "adjusting state when a prop changes." It's a render-phase setState, on purpose, and it doesn't loop, because prevCategoryCount actually changes to match, so the condition is false on the very next attempt.

When the real problem is one hook up, in a custom hook that syncs a value without comparing it, the fix is the same equality check, just relocated:

diff

  function useSyncedValue(external: number) {
    const [value, setValue] = useState(external);
-   setValue(external);
+   if (value !== external) {
+     setValue(external);
+   }
    return value;
  }

I'll say the quiet part: I don't love reaching for a second piece of state (prevCategoryCount) just to detect a change. It's an extra field to keep in sync forever, and it's easy to forget when you add a fifth prop later. useMemo derivation, fix #2, is the one I actually ship whenever the value doesn't need to survive its own re-render trigger. Reach for the guarded-setState pattern only when you truly need the reset to persist as independent state afterward — a modal's open flag that a prop change should force closed, for instance.

The shape of a component that can't loop

The version of FilterPanel I'd actually put in a PR doesn't have a toggle in state that fights with a derived condition at all. It separates "what's true given the current props" from "what the user chose," and only the second one is state:

tsx

import { useState } from 'react';

interface FilterPanelProps {
  categoryCount: number;
}

export function FilterPanel({ categoryCount }: FilterPanelProps) {
  const shouldSuggestExpand = categoryCount > 10;
  const [userExpanded, setUserExpanded] = useState<boolean | null>(null);
  const expanded = userExpanded ?? shouldSuggestExpand;

  return (
    <button onClick={() => setUserExpanded(!expanded)}>
      {expanded ? 'Collapse' : 'Expand'} ({categoryCount})
    </button>
  );
}

shouldSuggestExpand is derived, recomputed every render, free. userExpanded is null until a person actually clicks, at which point it overrides the suggestion. There is exactly one setter in this component, and it's called from exactly one place: an event handler. There's no condition to accidentally get wrong, because there's nothing left that runs unconditionally in the body.

The general version of this rule: if you can compute it from props and other state during render, it isn't state. It's a const. Reach for useReducer instead of scattered setters once two pieces of state have to change together, since a reducer makes "what causes what" explicit instead of implicit in a stack of if statements at the top of a component.

What I added to CI so it never comes back

eslint-plugin-react-hooks is currently at major version 7, and the flat-config recommended preset (reactHooks.configs.flat.recommended in eslint.config.js) ships the React Compiler-powered rule that exists specifically for this bug: react-hooks/set-state-in-render. It flags an unconditional setter call in a component body at lint time, before you ever open a browser tab. It does not flag the guarded reset pattern from fix #3, because the compiler can see the guard. That's the whole point of using the rule instead of a mental checklist: it can tell the two apart faster than a tired engineer at 2 a.m. can.

Pair it with react-hooks/purity (also part of the v7 compiler rules), which catches components doing non-idempotent things during render more broadly. This bug is a special case of "the render function isn't pure," and the purity rule catches variants the more specific rule doesn't name. Run tsc --noEmit with strict on in CI regardless; it won't catch this specific bug, but it's cheap insurance against the undefined-shaped bugs that show up in the same review.

On the process side: a Playwright smoke test that loads each route and fails the build on any console.error or uncaught exception catches this in five seconds, long before a human clicks through a staging deploy. It's the single highest-value CI check for this whole class of error, because the failure is deterministic and instant: there's no flake to chase, unlike a timing-dependent effect bug.

Five things worth keeping

React lets you call a setter during render on purpose. The guarded "adjust state on prop change" pattern is real and endorsed, not a hack.

The bug is never "you called setState during render." It's "you called it in a way where the thing you checked doesn't change when you check it again."

The retry loop never commits or paints. If you're seeing the DOM flicker, you're looking at a different error, probably #185.

StrictMode doesn't cause this and turning it off won't fix it; it just runs the same 25-attempt loop twice instead of once.

Before you touch the component under the error, check whether a custom hook one layer up is the one actually calling the setter. The stack trace names the hook, not always the habit.

Reactreact-hooksrerenderinguseStatedebuggingjavascriptfrontend-development

Engineer and writer behind CODELZ. I read the stack trace, reproduce the bug, and explain why it happens, not just how to silence it. Plus honest reviews of the books and tools worth your time.

Comments