React.memo
What you'll learn
- Exactly what
React.memodoes, and the precise meaning of "shallowly equal" - Why memo silently fails on inline objects, arrays, functions, and
children - The fixes:
useMemo,useCallback, hoisting, and composition - When memo genuinely helps and when it's dead weight
- Why memo doesn't shield against context, and the maintenance tax every memo boundary creates
Last chapter ended with a cliffhanger: we wrapped Chart in React.memo, and it didn't work until we stabilized a prop. That experience is universal. Developers sprinkle memo on components, the app stays slow, and they conclude "memo doesn't work." Memo works exactly as designed, the design is just stricter than people expect. This chapter makes the contract precise so your memo boundaries actually hold.
What React.memo actually is
React.memo wraps a component and returns a new component with a guard attached:
If the new props are shallowly equal to the previous props, skip re-rendering this component and its entire subtree, reuse the previous output.
import { memo } from 'react';
const Row = memo(function Row({ item, selected, onSelect }) {
console.count(`Row ${item.id} rendered`);
return (
<li
style={{ fontWeight: selected ? 'bold' : 'normal' }}
onClick={() => onSelect(item.id)}
>
{item.name}
</li>
);
});
Jargon: "shallow compare". Comparing two objects by walking only their top-level keys and checking each value with
Object.is. No deep walking into nested objects.{a: 1}vs{a: 1}passes;{a: {b: 2}}vs a fresh{a: {b: 2}}fails, because the two inner objects are different objects in memory.
Pseudocode model, not real source:
// What the memo guard conceptually does:function renderMemoized(Component, newProps) {if (renderedBefore && shallowEqual(prevProps, newProps)) {return previousOutput; // skip: don't call Component at all}const output = Component(newProps);prevProps = newProps;previousOutput = output;return output;}function shallowEqual(a, b) {const keys = Object.keys(a);if (keys.length !== Object.keys(b).length) return false;return keys.every((k) => Object.is(a[k], b[k]));}
Two things to internalize from the model:
- When the compare passes, your function is not called. Not "called but cheap", not called. The entire subtree's previous output is reused.
- The compare itself costs something. Every parent render, React pays for the shallow walk. Memo is a bet: "comparing props is cheaper than rendering this subtree." Usually true for big subtrees; often false for tiny ones.
The worked example: a 500-row list
import { memo, useState } from 'react';
const Row = memo(function Row({ item, selected, onSelect }) {
console.count(`Row ${item.id} rendered`);
return (
<li
style={{ fontWeight: selected ? 'bold' : 'normal' }}
onClick={() => onSelect(item.id)}
>
{item.name}
</li>
);
});
export default function ListPage() {
const [items] = useState(() =>
Array.from({ length: 500 }, (_, i) => ({ id: i, name: `Item ${i}` }))
);
const [selectedId, setSelectedId] = useState(null);
function handleSelect(id) {
setSelectedId(id);
}
return (
<ul>
{items.map((item) => (
<Row
key={item.id}
item={item}
selected={item.id === selectedId}
onSelect={handleSelect}
/>
))}
</ul>
);
}
What happens when you click row 7:
handleSelect(7)callssetSelectedId(7).ListPagere-renders.- The cascade reaches 500 memoized
Rows. For each, React shallow-compares props. item, the same object from the sameitemsarray (created once, never recreated). Same identity. ✓selected,item.id === selectedId. For 498 rows this boolean is unchanged. For row 7 it flippedfalse → true; for the previously selected row it flippedtrue → false.onSelect,handleSelectis a stable function declared once in the component... wait, no, it's re-declared every render! A new function identity each time. ✗
Step 5 is the trap: as written, onSelect is a fresh function per render, so every row's compare fails and all 500 re-render. The fix, shown next, is useCallback. With that fixed: only 2 of 500 rows re-render, the one that got selected and the one that got deselected. That's the memo payoff.
Failure mode 1: inline object and array props
// ❌ Memo never fires: fresh object every parent render
<Chart data={[4, 9, 16, 25]} style={{ width: 400 }} />
Every render of the parent creates new data and style objects. New identity → Object.is fails → compare fails → memo renders anyway. Every single time.
Fixes, in order of preference:
// ✅ Hoist true constants outside the component
const CHART_DATA = [4, 9, 16, 25];
const CHART_STYLE = { width: 400 };
// ✅ Or useMemo when the value is computed from reactive inputs
const filtered = useMemo(
() => items.filter((i) => i.active),
[items]
);
Jargon: "stable identity". A value that keeps the same object/function identity across renders, so
Object.is(old, new)is true. Memo,useEffectdeps, anduseMemodeps all depend on stable identities to work.
Failure mode 2: inline function props
// ❌ Fresh arrow function every render → memo defeated
<Row item={item} onSelect={() => select(item.id)} />
Arrow functions in JSX are re-created on every render of the parent. New identity, compare fails.
Fix: useCallback.
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []); // setSelectedId is stable, so this function never changes
useCallback returns the same function object across renders until a dependency changes. Now onSelect passes the shallow compare. (The exhaustive-deps rules for useCallback and useMemo are exactly the same as useEffect's, Part 3 covered why lying in the array creates stale closures.)
Failure mode 3: the children prop
This one defeats memo even when you did everything else right:
// ❌ Looks memoized. Isn't.
<MemoPanel title="Stats">
<p>Some expensive content</p>
</MemoPanel>
Why it fails, what happens:
- Remember: JSX children are just elements, and elements are objects created when the parent's JSX runs.
- Every time the parent re-renders,
<p>Some expensive content</p>is created fresh, a brand-new object. MemoPanel's props includechildren: <that new object>. Shallow compare:Object.is(oldChildren, newChildren)→false. Memo fails. Every time.
Fixes: hoist the children if they're truly static, or, usually better, flip the composition: pass the stable, expensive part as children from a grandparent that doesn't re-render (the Layout pattern from the last chapter). Then the freshness of children doesn't matter because the state isn't in the parent that creates them.
// ✅ Composition: ExpensivePanel isn't memoized at all — it never needs to be,
// because Page doesn't re-render when Layout's state changes.
function Layout({ children }) {
const [open, setOpen] = useState(true);
return (
<section>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</section>
);
}
export default function Page() {
return (
<Layout>
<ExpensivePanel />
</Layout>
);
}
When memo helps: and when it's dead weight
Memo helps when:
- Large lists and trees, hundreds of siblings where typically one item's props change (the 500-row list: 498 renders skipped).
- Genuinely expensive components, heavy computation or huge element subtrees, where render cost dwarfs compare cost.
- Components far from the state, a deep leaf that re-renders only because state lives at the top of the tree.
Memo does nothing when:
- The component is tiny. Comparing three props costs about the same as calling a function that returns two elements. You added machinery for zero gain.
- Props are always unstable. If every render passes fresh objects/functions, the compare fails 100% of the time, pure overhead, and a false sense of security.
- The root cause is elsewhere. If the real problem is state placed too high, memo treats the symptom forever while colocation cures the disease once. Colocate first; memo what remains.
Memo + context: the blind spot
A memoized component that calls useContext re-renders whenever that context's value changes, regardless of props. The memo guard sits at the props door; context walks in through a side entrance.
const UserBadge = memo(function UserBadge({ size }) {
const theme = useContext(ThemeContext); // re-renders on theme change
return <span style={{ color: theme }}>★</span>; // even if size never changes
});
This is by design: the component demonstrably reads a value that changed, so skipping its render could show stale UI. If a hot context change is hammering a memoized subtree, the fixes are splitting contexts (Part 3) or moving the context read into a small dedicated consumer and passing plain props down to the memoized bulk.
The memo maintenance tax
Every memo boundary is a contract you must maintain forever: every prop crossing it, present and future, must keep a stable identity. Add one innocent options={{...}} literal six months later and the boundary silently dies; the Profiler just shows the component rendering again, and nobody remembers why it was supposed not to.
Memo is also viral: for a deep subtree to be skipped, every boundary on the path must hold. One unstable prop anywhere up the chain re-renders everything below it. At scale, hand-maintained memoization is a tax on every future edit, which is exactly the pain the React Compiler (next chapter) exists to automate away.
Diagram
Rendered diagram (PNG hi-res):
Rendered diagram (PNG hi-res):
Common misconceptions
- Misconception:
memodeep-compares props. Reality: shallow only, top-level keys viaObject.is. A fresh nested object with identical contents fails the compare. - Misconception: memo stops all re-renders of a component. Reality: it only blocks the parent-cascade. Own state changes and consumed context changes still render it.
- Misconception: Wrapping everything in memo makes apps faster. Reality: each boundary costs a compare on every parent render, and on tiny components the compare ≈ the render. You can make an app slower with universal memo.
- Misconception:
useCallbackmakes functions "faster". Reality: it just preserves identity so memo (and effect deps) work. The function still gets created; the old one is returned instead. - Misconception:
<MemoComp><div /></MemoComp>is protected by memo. Reality:childrenis a prop, a fresh element every parent render, so the compare fails. Composition, not memo, is the fix. - Misconception: If a memoized component still renders, memo is broken. Reality: some prop is unstable. The Profiler's "why did this render" plus a prop-by-prop identity check will find which one.
Why it works this way
- Shallow compare is the sweet spot of cost vs coverage. Deep comparison is itself expensive, sometimes as expensive as rendering, and can hide mutations that would be bugs anyway. Shallow equality is cheap, predictable, and rewards immutable data, which React already requires.
- Skipping by identity is safe because elements and props are treated as immutable. If nothing reachable from the props changed identity, the output genuinely cannot differ, provided your components are pure, which the rules of React already demand.
- Context bypasses memo deliberately. A context change is a demonstrated dependency of the component; honoring the memo guard there would serve stale UI. Props can be checked; consumed context cannot.
- Memo is opt-in because the default must be correctness. React can't know your components are pure and your props stable; it re-renders by default and lets you declare "trust me, skip when equal" where it pays.
Try it yourself
- Build the 500-row list without memo. Click a row and watch all 500
console.counts fire. Add memo, confirm they still all fire (theonSelecttrap). AdduseCallback, confirm only 2 rows render. - Add an inline
style={{ margin: 2 }}prop toRow. Predict what happens to the counts, then verify. Fix it with a hoisted constant. - Wrap a memoized component around a
<div>child as in<MemoPanel><p/></MemoPanel>. Prove withconsole.countthat memo fails, then fix it with theLayoutcomposition pattern. - Give the memoized
RowauseContext(ThemeContext)read and toggle the theme from a button. Confirm the guard is bypassed for every consumer.
Recap
React.memo: if props are shallowly equal (Object.ison each top-level key), skip the render and reuse previous output, the component is not called.- Memo is a bet that comparing props beats rendering the subtree. It wins for big lists, expensive components, and far-from-state leaves; it's dead weight on tiny components.
- Three silent killers: inline objects/arrays (fix:
useMemoor hoist), inline functions (fix:useCallback), andchildren(fix: composition/hoisting). - Context changes bypass memo entirely, consumers re-render regardless of the guard.
- Every memo boundary is a contract: all props, forever, must stay stable. One unstable prop up the chain breaks everything below.
- Colocate state and compose with children first; memo what remains. Next chapter: making the compiler maintain these boundaries for you.