React Too Many Re-renders - setState Called in the Render Body
5 min read
“The page loads and the component is already gone. No click, no input, nothing. React hit its re-render limit before the first frame ever painted - because something in the component body called setState during render.”
The page loads. The component area is blank. You didn't click anything.
There was no user interaction. The component simply never appeared.
Open the console:
Too many re-renders. React limits the number of renders to prevent an infinite loop.
React terminated your component before it could produce any visible output,
because the component triggered its own re-render before it even finished
rendering the first time.
React expects your component function to be pure - given the same inputs
(props and state), it should return the same output (JSX) every time, with
no side effects during the calculation. It can read state, it can compute
values, it can build JSX. It should not change state while doing any of
that.
Calling a state setter - setState, setCount, setUser, anything from
a useState pair - is a side effect. It tells React "schedule a re-render
with this new value." That scheduling is fine in an event handler (runs
after render), fine in a useEffect (runs after render). In the render
body itself, it fires during render, which immediately queues another
render, which calls the setter again, which queues another render. React
cuts this off at a hard limit and throws rather than let it run forever.
setCount(INBOX_COUNT) sits directly in the component body, between the
useState call and the return. Every time NotificationBadge renders,
this line runs. It calls setCount(5), which schedules a re-render. That
re-render runs the body again. setCount(5) fires again. Another
re-render. React hits its limit and throws - all before the JSX ever makes
it to the screen.
Symptom
The component area is blank from the moment the page loads. No partial
render, no flash of content, no recovery. The crash happens before
anything is committed to the DOM - there is nothing to show and nothing
to interact with.
The intent behind this code is understandable: "I want to initialize count
to INBOX_COUNT when the component loads." That's a correct goal. The
problem is using setCount to do it. useState already accepts the
initial value as an argument - that's exactly what the argument is for.
const INBOX_COUNT = 5;export default function NotificationBadge() { const [count, setCount] = useState(INBOX_COUNT); return ( <div> <p>Unread: {count}</p> <button onClick={() => setCount((c) => c - 1)}>Mark as Read</button> </div> );}
useState(INBOX_COUNT) sets count to 5 on the first render and never
runs again. No setter is called, no re-render is scheduled, and the
component renders cleanly - Unread: 5 on the first paint, clicking "Mark
as Read" decrements correctly from there.
The setCount call and the useState argument are not two ways of doing
the same thing. useState(value) sets the initial state once, at
component birth. setCount(value) schedules a re-render with a new state
value. The first is initialization. The second is an update. Calling an
update setter during render is the bug.
The Sensei's Hint
If you want state to start at a value that isn't a literal - a prop, a
constant, a computed expression - pass it directly to useState:
useState(props.initialCount), useState(items.length),
useState(INBOX_COUNT). The argument runs once, at mount, and is ignored
on every subsequent render. No setter needed, no effect needed.
If the initial value is expensive to compute - parsing a large JSON string,
reading from localStorage, running a heavy calculation - you can pass a
function to useState instead of a value. React calls it once, on mount,
and uses the return value as the initial state:
This is the "lazy initializer" form. The function is called once and never
again, which makes it safe for expensive operations. The key difference from
the bug above: this is a function passed touseState, called by React
at the right time. Calling setData directly in the render body is still
wrong regardless of how that call is wrapped.
The render body / setter combination is the most common form, but the same
crash happens any time a setter is called unconditionally during render:
// crashes - setter called on every renderconst [doubled, setDoubled] = useState(0);setDoubled(count * 2); // move this to a useEffect or just compute it inline
// crashes - same thing, just structured differentlyif (someCondition) { setError("something went wrong"); // called during render}
Both of these have the same loop: render calls setter, setter schedules
render, render calls setter. The fix is always the same direction - move
the setter call out of the render body and into the place where it belongs:
an event handler for user-triggered updates, a useEffect for
synchronization with external things, or a plain const expression for
values that can be computed inline.
Constraints
If a value can be derived directly from existing state - count * 2,
items.length, a formatted string from a name - compute it as a const
in the render body, not via a setter. A derived const recalculates
during render, which is correct. A setter during render causes a loop.
The distinction is: reading and computing is fine; scheduling a state
update is not.
"Notification Badge Never Appears" is the pattern in its minimal form - one
constant, one misplaced setter, instant crash on mount. The fix is literally
moving the value from setCount(INBOX_COUNT) to useState(INBOX_COUNT) -
one change, nine characters different. Work through it to build the habit of
asking "does this setter call belong here, or is there somewhere better for
it to live?"