In one minute
Every re-render starts with a state change (setState, dispatch, or a context value changing). From there it cascades: a component that re-renders re-renders all of its children by default, whether their props changed or not. “Props changed” is not a trigger; the parent rendering is. You can stop the cascade at a child with memo, which skips the child when every prop is Object.is-equal to last time. That only works if the props keep their identity, which is what useMemo (values) and useCallback (functions) are for. The same identity rules explain stale closures: a function keeps the values of the render that created it.
You’ll be able to: predict who re-renders, make memo actually work when it’s worth it, and fix stale closures.
1function CartPage() {2 const [count, setCount] = useState(0)3 return (<>4 <button onClick={() => setCount(c => c + 1)}>Add ({count})</button>5 <Recommendations options={{ limit: 3 }} />6 <CheckoutButton onCheckout={() => log('checkout')} />7 <Footer />8 </>)9}- event
<button onClick={() => setCount(c => c + 1)}>Add ({count})</button>A state change inCartPage: the only real trigger. - render phase
const [count, setCount] = useState(0)CartPageruns again. - render phase
<Recommendations options={{ limit: 3 }} />It creates a newoptionsobject and a new function.memocompares props withObject.is, sees “changes”, and renders both anyway. - commit phase
<Footer />Footerisn’t memoized, so it renders whenever its parent does. Recorded: all four rendered;memodid nothing.
1function CartPage() {2 const [count, setCount] = useState(0)3 const options = useMemo(() => ({ limit: 3 }), [])4 const onCheckout = useCallback(() => log('checkout'), [])5 return (<>6 <button onClick={() => setCount(c => c + 1)}>Add ({count})</button>7 <Recommendations options={options} />8 <CheckoutButton onCheckout={onCheckout} />9 <Footer />10 </>)11}- event
<button onClick={() => setCount(c => c + 1)}>Add ({count})</button>The same click. - render phase
const options = useMemo(() => ({ limit: 3 }), [])CartPageruns, butuseMemoanduseCallbackreturn the same object and function as last time. - render phase
<Recommendations options={options} />Every prop isObject.is-equal, somemoskips both children. - commit phase
<Footer />Footerstill renders: it has nomemo. Recorded: onlyCartPageandFooter.
The example: the cart page
function Footer() {
log(' Footer renders')
return <footer>Free shipping over $50</footer>
}
const Recommendations = memo(function Recommendations({ options }: { options: { limit: number } }) {
log(` Recommendations renders (limit ${options.limit})`)
return <aside>Top {options.limit} picks</aside>
})
const CheckoutButton = memo(function CheckoutButton({ onCheckout }: { onCheckout: () => void }) {
log(' CheckoutButton renders')
return <button onClick={onCheckout}>Checkout</button>
})
function CartPage({ stable }: { stable: boolean }) {
const [count, setCount] = useState(0)
log(`CartPage renders (count ${count})`)
const inlineOptions = { limit: 3 }
const memoOptions = useMemo(() => ({ limit: 3 }), [])
const inlineCheckout = () => log('checkout')
const memoCheckout = useCallback(() => log('checkout'), [])
return (
<>
<button id="add" onClick={() => setCount((c) => c + 1)}>Add ({count})</button>
<Recommendations options={stable ? memoOptions : inlineOptions} />
<CheckoutButton onCheckout={stable ? memoCheckout : inlineCheckout} />
<Footer />
</>
)
}
One click on “Add”, with the props written inline (stable = false):
CartPage renders (count 1)
Recommendations renders (limit 3)
CheckoutButton renders
Footer renders
Everything rendered. Footer has no memo, so it renders with its parent. The two memoized children rendered too: { limit: 3 } and () => … are new objects every render, so memo saw “changed” props.
With useMemo and useCallback (stable = true):
CartPage renders (count 1)
Footer renders
The memoized children were skipped.
Primitives vs objects
Object.is compares numbers, strings and booleans by value, and objects, arrays and functions by identity:
Object.is(3, 3) // true
Object.is({ limit: 3 }, { limit: 3 }) // false
Object.is(() => {}, () => {}) // false
Anything created in a component body is a new object on every render. This one fact explains failing memos, effects that re-run every render (DOM Refs and Effect Dependencies) and state updates that don’t render (Building a Cart).
Stale closures
A closure keeps the variables of the scope that created it. In React, each render is a separate call with its own variables, so a function created in render 1 sees render 1’s state forever.
function Timer({ fixed }: { fixed: boolean }) {
const [seconds, setSeconds] = useState(0) // one tick every 100 ms
useEffect(() => {
const id = setInterval(() => {
if (fixed) setSeconds((s) => s + 1)
else setSeconds(seconds + 1) // seconds is always 0 in this closure
}, 100)
return () => clearInterval(id)
}, [])
return <p id="timer">Ticks: {seconds}</p>
}
After one second (a tick every 100 ms):
setSeconds(seconds + 1):
Ticks: 1
setSeconds((s) => s + 1):
Ticks: 16
The interval was created once ([]), so its closure always sees seconds = 0 and keeps setting 1. Fixes, in order of preference: an updater function (doesn’t need the current value), a reducer (dispatch reads nothing, see useState vs useReducer), adding the value to the dependencies (the interval is recreated when it changes), or a ref that always holds the latest value.
Components defined inside components
function SearchPage() {
const [n, setN] = useState(0)
function SearchBox() {
// defined inside SearchPage: a new component type on every render
return <input id="inner" />
}
return (
<>
<SearchBox />
<button id="bump" onClick={() => setN(n + 1)}>Re-render ({n})</button>
</>
)
}
Typed “mug”, then clicked “Re-render”:
{
"logs": [],
"warnings": [],
"before": "mug",
"after": ""
}
SearchBox is a new function on every render of SearchPage, so React sees a different component type at that position, unmounts the old one and mounts a new one. The typed text is gone. Define components at the top level of the module.
When to optimize
- Re-renders are normally cheap; most components render in well under a millisecond. Measure first with the React DevTools Profiler (enable “Record why each component rendered”).
- Reach for
memo+useMemo/useCallbackwhen a child is measurably slow and its props can be kept stable.useMemois also for genuinely expensive calculations. - Often a structural fix is simpler: move state down (Lifting State) or pass slow parts as
childrenso they aren’t re-created by the state owner. - The React Compiler adds this memoization automatically where it’s safe; in projects that use it, hand-written
useMemo/useCallbackare rarely needed. - One big context re-renders every consumer on any change; splitting contexts by how often they change helps (React Performance module).
Common mistakes
- Believing changed props cause re-renders, or that unchanged props prevent them.
memoon a child that receives inline objects or functions.- Wrapping everything in
useMemo/useCallbackwithout measuring. - Effects or intervals with
[]that read changing state.
Interview Q&A
What causes a component to re-render?
Its own state changing, its parent re-rendering (children re-render by default), or a context it reads changing. All of these start from some state change; props changing isn’t a separate trigger.
Why does memo sometimes not prevent a re-render?
memo compares props with Object.is. An object, array or function created during the parent’s render is new every time. Recorded: with inline options and onCheckout, both memoized children rendered; with useMemo/useCallback, they were skipped.
What is a stale closure in React?
A function that captured state from the render that created it and keeps using it after the state changed. Recorded: an interval set up once with [] stayed at Ticks: 1; with an updater function it counted up to 16.
How do you fix a stale closure?
Use an updater function or a reducer so the code doesn’t read the captured value; or list the value in the dependencies so the function is recreated; or keep the latest value in a ref.
What's the difference between useMemo and useCallback?
useMemo(() => value, deps) keeps a computed value; useCallback(fn, deps) keeps a function, and equals useMemo(() => fn, deps). Both exist to keep identity (or skip expensive work) between renders.
Why not define a component inside another component?
Each render creates a new component type, so React remounts it and its state is lost. Recorded: the typed “mug” was empty after one re-render.
Related
- Lifting State: moving state down to re-render less.
- Reconciliation (React Internals): why a new type at the same position remounts.
- React Performance module: memoizing elements, context and lists in depth.
Sources
- Josh W. Comeau, Why React re-renders, Understanding useMemo and useCallback
- Nadia Makarevich, React re-renders guide
- Dmitri Pavlutin, Be aware of stale closures when using React hooks
- react.dev:
memo,useMemo,useCallback, React Compiler
Primary source: www.joshwcomeau.com/react/why-react-re-renders/