Memoization: referential equality is the contract
useMemo caches a value, useCallback a function identity, React.memo skips re-renders on shallow-equal props — all contracts over referential equality. Inline objects and children defeat memo; cheap or always-changing deps make memoization a net loss. Profile first, then memoize.
The trading dashboard was janky — typing in the symbol filter dropped frames. A well-meaning PR landed titled “perf: memoize everything”: 47 useMemo calls, 31 useCallback calls, React.memo on every component in the tree. Code review approved it on vibes. The Profiler told a different story two weeks later: commit times were up 12%, and the one component that mattered — a 3,000-row positions table — was still re-rendering on every keystroke. Every row was wrapped in React.memo, and every row received style={{ height: rowHeight }} and onSelect={() => select(row.id)} from the parent. Both props were rebuilt every render, both failed the shallow comparison, so React.memo dutifully compared 3,000 × 6 props per keystroke and then re-rendered everything anyway — all of the cost, none of the benefit. Meanwhile the 47 useMemo calls were caching things like firstName + ' ' + lastName, paying a dependency comparison on every render to avoid work cheaper than the comparison. Memoization is not a sprinkle-on optimization; it is a contract about reference identity, and a contract one party silently breaks is pure overhead.
What each tool actually caches
Three tools, three different cached things, one shared mechanism. useMemo(fn, deps) runs fn during render and caches its return value; on later renders, if every dep matches the previous render by Object.is, it returns the cached value without calling fn. useCallback(fn, deps) caches the function object itself — it never calls fn — and is literally useMemo(() => fn, deps). It does not make the function faster, doesn’t cache results of calling it; it preserves the function’s identity across renders. React.memo(Component) wraps a component: when the parent re-renders, React compares the new props object to the previous one shallowly — Object.is on each prop — and skips re-rendering the wrapped component (and reuses its last output) if all props match.
Note what is not on this list: none of these prevent re-renders from the component’s own state changes or from context it consumes. React.memo only filters re-renders propagating from the parent. And useMemo’s cache is per component instance, holds exactly one entry (the last one), and React reserves the right to throw it away — it’s a performance hint, not a semantic guarantee, which is why memoized code must produce the same result as un-memoized code.
Referential equality is the contract — and how it gets broken
All three tools answer the same question: “is this the same as last time?” — and answer it with reference identity, not structural equality. That makes them composable and fast (a handful of pointer comparisons), and it makes them brittle in one specific way: anything rebuilt during render is new. Object literals, array literals, arrow functions, and — the one that surprises senior candidates — JSX children. <Row>{label}</Row> creates a new React element object every render; an element is just { type, props, ... }, so a memoized component receiving children gets a fresh children prop reference each time, and the shallow comparison fails. The Hook’s table is the canonical case:
const Row = memo(function Row({ item, style, onSelect }) {
/* expensive subtree */
});
// ❌ memo defeated twice per row, every parent render:
rows.map((item) => (
<Row
key={item.id}
item={item}
style={{ height: rowHeight }} // new object identity each render
onSelect={() => select(item.id)} // new function identity each render
/>
));
// ✅ stable references — memo can actually bail out:
const style = useMemo(() => ({ height: rowHeight }), [rowHeight]);
const onSelect = useCallback((id) => select(id), [select]);
rows.map((item) => (
<Row key={item.id} item={item} style={style} onSelect={onSelect} />
));This is why useCallback exists at all: not to optimize the function, but to keep a child’s React.memo (or another hook’s deps array) from seeing a new reference every render. The contract is transitive and fragile — one inline object prop anywhere in the chain, and every memo below it does comparison work for nothing. The failure is silent: no warning, no error, just a Profiler showing the “memoized” subtree re-rendering on every commit. Production story in one line: a team memoized a chart component and shipped it; six months later someone added margin={{ top: 8 }} to the call site and the chart silently went back to re-rendering 60 times a second, surviving two more quarters because nothing broke — it just burned CPU.
A component is wrapped in React.memo but the Profiler shows it re-rendering on every parent render. Its props are value (a number) and onChange (an inline arrow function at the call site). Why does memo not bail out?
When memoization is a net loss
Memoization is never free. Every useMemo/useCallback costs: the deps array allocation each render, the per-element Object.is walk each render, the cache slot held in memory for the component’s lifetime, and on the first render it is strictly slower (cache write plus the work). React.memo costs a shallow props walk on every parent render. These costs are small — a deps comparison is tens of nanoseconds — but the benefit can be smaller, and then you’ve made the code slower and harder to read:
- The computation is cheaper than the comparison.
useMemo(() => first + ' ' + last, [first, last]): a string concat is comparable to or cheaper than twoObject.ischecks plus an array allocation. The Hook’s 47-useMemo PR was mostly this — pure overhead with a perf label on it. - The deps change every render anyway. Memoizing with a dep that is itself rebuilt per render (an inline object, an unstable function from a non-memoized parent) means a 0% cache hit rate: full memoization cost, zero avoided work. This is the worst case — it looks optimized.
- The skipped subtree is trivial. React.memo on a component that renders two spans saves microseconds per skip while paying the props walk on every parent render. Memoization pays when the avoided work is large: re-render of a subtree with hundreds of components (milliseconds), an O(n log n) sort over thousands of rows, a layout-thrashing chart redraw.
The decision procedure is empirical, not aesthetic. When you are tempted to sprinkle useMemo across a component, open the React DevTools Profiler first, record the slow interaction, and read which commits are slow and which components actually take the time. Memoize the proven-expensive thing and the reference chain feeding it — and verify in the Profiler that the bailout now happens. A memo you didn’t verify is a memo that is probably broken by an inline prop somewhere, like the Hook’s 3,000 rows.
▸Why this works
Why does React make you do this by hand at all — why not compare props structurally, or cache everything automatically? Structural comparison is unbounded: deep-comparing a props tree on every render can cost more than re-rendering, and cycles and functions make it ill-defined. So React picked the cheapest possible check, reference identity, and left the stability of references to you. That decision is also why this is fixable by a compiler: whether a value is rebuilt per render is statically analyzable from the code. The React Compiler (stable releases began in 2025) does exactly that — it auto-memoizes values, functions, and JSX at build time, following the same Rules of React your hand-written memo already depends on, and it removes most manual useMemo/useCallback from new code. Honest framing: it is the stated direction and it works on real codebases, but it assumes rule-compliant components, and understanding referential equality remains load-bearing — it is what the compiler is automating, what you debug when a bailout doesn’t happen, and what every existing codebase still runs on.
Profiling before memoizing
A concrete workflow that survives code review. One: reproduce the jank and record it in the Profiler — flame chart, ranked chart, “why did this render?” enabled. Two: identify the expensive commit and the components dominating it; typical findings are a wide list re-rendering wholesale, or one component doing heavy compute in render. Three: fix the cause — often that’s restructuring (move state down so the keystroke doesn’t render the page shell; pass JSX as children so the static part is created once by the parent and keeps its identity) before it’s memoization. Four: where memoization is the answer, apply it as a chain — memo on the expensive child, useCallback/useMemo on every reference that child receives — because a chain with one unstable link is all cost, no benefit. Five: re-record and confirm the bailout. Numbers from the Hook’s dashboard after doing it in this order: keystroke commit 38ms → 4ms, achieved by deleting 40 of the 47 useMemo calls, memoizing the row props properly, and moving the filter input’s state out of the table’s parent.
A teammate writes useMemo(() => items.filter(isActive), [items]) where items is a new array from a non-memoized selector on every render. What did this useMemo achieve?
- 01State precisely what useMemo, useCallback, and React.memo each cache, and the one mechanism they share.
- 02Give the three situations where memoization is a net loss, and the workflow that avoids them.
The three memoization tools cache three different things over one mechanism. useMemo caches a computed value and recomputes only when a dep fails Object.is against the previous render; useCallback caches a function’s identity without calling it and is sugar for useMemo returning the function; React.memo shallow-compares props per element and skips re-rendering the wrapped component when all match — while doing nothing about re-renders from the component’s own state or its consumed context. The shared contract is referential equality: a handful of pointer comparisons, deliberately chosen over structural comparison because deep equality is unbounded work. The contract’s fragility follows directly — anything rebuilt during render is a new reference, so an inline object, an inline arrow, or JSX children (a fresh element object every render) silently defeats every memo downstream, leaving the comparison cost with none of the benefit, with no warning except a Profiler showing the subtree re-rendering. Memoization is also a measurable net loss when the computation is cheaper than the deps bookkeeping, when a dep changes identity every render (0% hit rate), or when the skipped subtree is trivial. So the discipline is empirical: profile the slow interaction, prefer structural fixes first — moving state down so a keystroke renders less, passing static JSX as children to preserve its identity — then memoize the proven-expensive subtree together with the full reference chain feeding it, and re-profile to confirm the bailout actually occurs. The React Compiler automates exactly this at build time for rule-compliant components and is the stated direction, removing most manual useMemo and useCallback from new code — but what it automates is this same referential-equality contract, which is what you debug when a bailout fails and what every existing codebase still depends on. Now when you see a memoized component re-rendering anyway, you know the first place to look: one inline object or arrow function somewhere in the props chain, silently defeating every memo downstream.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.