Must knowHooks · 09

React Re-rendering

What triggers a re-render, why memo fails on new objects, stale closures, and when to use useMemo/useCallback.

On this page · 10 sections
  1. In one minute
  2. The example: the cart page
  3. Primitives vs objects
  4. Stale closures
  5. Components defined inside components
  6. When to optimize
  7. Common mistakes
  8. Interview Q&A
  9. Related
  10. Sources

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.

Inline props
CartPage.tsx
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}
What you see
Add (0)
Rendered this step
CartPage–
Recommendations (memo)–
CheckoutButton (memo)–
Footer–
  1. event<button onClick={() => setCount(c => c + 1)}>Add ({count})</button>A state change in CartPage: the only real trigger.
  2. render phaseconst [count, setCount] = useState(0)CartPage runs again.
  3. render phase<Recommendations options={{ limit: 3 }} />It creates a new options object and a new function. memo compares props with Object.is, sees “changes”, and renders both anyway.
  4. commit phase<Footer />Footer isn’t memoized, so it renders whenever its parent does. Recorded: all four rendered; memo did nothing.

0 / 0
One click on “Add” in a cart page. Who re-renders depends on memo and on whether props keep the same identity (both recorded).

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

Common mistakes

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.

Sources

Primary source: www.joshwcomeau.com/react/why-react-re-renders/