Memoization in Practice
Three tools, three jobs
Section titled “Three tools, three jobs”Memoization means caching a result between renders so React can skip recomputing or re-rendering. There are three tools, and they do different jobs:
useMemo(fn, deps)— caches the return value of an expensive computation, recomputing only when a dependency changes.useCallback(fn, deps)— caches a function’s identity, returning the same function reference across renders until a dependency changes.React.memo(Component)— wraps a component so it skips re-rendering when its props are shallow-equal.
// useMemo: skip re-sorting a big list unless the list or order changes.const sorted = useMemo(() => items.slice().sort(compare), [items]);
// useCallback: keep a stable onSelect so a memoized child does not re-render.const onSelect = useCallback((id) => setSelected(id), []);
// React.memo: skip re-render when props are unchanged.const Row = React.memo(function Row({ item, onSelect }) { /* ... */ });Why useCallback and React.memo are a pair
Section titled “Why useCallback and React.memo are a pair”React.memo compares props with shallow equality (=== per prop). Primitives compare by value, but objects, arrays, and functions compare by reference — and a component creates brand-new ones on every render.
flowchart TB parent["Parent re-renders"] --> inline["creates NEW onClick, NEW style object"] inline --> memo["React.memo(Child) compares props"] memo --> fail["new reference !== old reference → shallow-equal fails → Child re-renders anyway"]
// ❌ A fresh function and object every render — React.memo(Child) never skips.<Child onClick={() => doThing()} style={{ color: "red" }} />
// ✅ Stable references — now React.memo(Child) can actually skip.const onClick = useCallback(() => doThing(), []);const style = useMemo(() => ({ color: "red" }), []);<Child onClick={onClick} style={style} />This is the crux: React.memo only pays off when every prop is referentially stable, which usually means memoizing the object/array/function props with useMemo/useCallback. Get one prop wrong and the memo does nothing.
Proof: an inline function defeats React.memo; useCallback fixes it
Section titled “Proof: an inline function defeats React.memo; useCallback fixes it”import { memo, useState, useCallback } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach } from 'vitest';
afterEach(cleanup);
let rowRenders = 0;
const Row = memo(function Row({ onSelect }: { onSelect: () => void }) { rowRenders++; return <li onClick={onSelect}>row</li>;});
test('a fresh inline function prop defeats React.memo — the child re-renders on every parent render', () => { rowRenders = 0; function Parent() { const [, setTick] = useState(0); // 🔴 a NEW function every render. return <Row onSelect={() => setTick((t) => t + 1)} />; } const { rerender } = render(<Parent />); expect(rowRenders).toBe(1); rerender(<Parent />); rerender(<Parent />); expect(rowRenders).toBe(3); // memo did nothing — re-rendered every time});
test('useCallback keeps the reference stable, so React.memo actually skips the re-render', () => { rowRenders = 0; function Parent() { const [, setTick] = useState(0); const onSelect = useCallback(() => setTick((t) => t + 1), []); return <Row onSelect={onSelect} />; } const { rerender } = render(<Parent />); expect(rowRenders).toBe(1); rerender(<Parent />); rerender(<Parent />); expect(rowRenders).toBe(1); // stable reference — memo skips the re-render});✓ a fresh inline function prop defeats React.memo — the child re-renders on every parent render✓ useCallback keeps the reference stable, so React.memo actually skips the re-render
Test Files 1 passed (1) Tests 2 passed (2)See it render: inline object vs. useMemo
Section titled “See it render: inline object vs. useMemo”import { useState, useMemo, memo } from 'react';
const MemoChild = memo(function MemoChild({ style }: { style: { color: string } }) { return <li style={style}>Memo child</li>;});
export default function App() { const [tick, setTick] = useState(0); const [useStable, setUseStable] = useState(false); const stableStyle = useMemo(() => ({ color: 'inherit' }), []); const style = useStable ? stableStyle : { color: 'inherit' }; return ( <div> <button onClick={() => setTick((t) => t + 1)}>Parent tick: {tick}</button> <button onClick={() => setUseStable((s) => !s)}> Mode: {useStable ? 'useMemo (stable)' : 'inline object (fresh every render)'} </button> <ul> <MemoChild style={style} /> </ul> </div> );}| Component | Renders | Last commit (ms) |
|---|---|---|
| Click Run — commits will appear here. | ||
Click Parent tick while the mode reads “inline object” — MemoChild’s row climbs on every click, because { color: 'inherit' } is a brand-new object each render. Now click the mode button to switch to “useMemo (stable)” and click Parent tick again: MemoChild’s row stops moving entirely, because the same useMemo’d object reference now passes React.memo’s shallow comparison every time.
useCallback alone is not enough — the child must be memoized too
Section titled “useCallback alone is not enough — the child must be memoized too”useCallback only produces a stable reference. That reference only stops a re-render if something downstream actually checks it for equality — which means the component receiving it has to be wrapped in React.memo. A stable callback handed to a plain, non-memoized component changes nothing: that component re-renders whenever its parent does, exactly like any other child, regardless of how stable its props are.
import { memo, useState, useCallback } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach } from 'vitest';
afterEach(cleanup);
let plainRenders = 0;let memoRenders = 0;
function PlainRow({ onSelect }: { onSelect: () => void }) { plainRenders++; return <li onClick={onSelect}>row</li>;}
const MemoRow = memo(function MemoRow({ onSelect }: { onSelect: () => void }) { memoRenders++; return <li onClick={onSelect}>row</li>;});
test('useCallback alone does not stop a re-render — the child must be memoized too', () => { plainRenders = 0; function Parent() { const [, setTick] = useState(0); const onSelect = useCallback(() => setTick((t) => t + 1), []); // PlainRow is NOT wrapped in React.memo. return <PlainRow onSelect={onSelect} />; } const { rerender } = render(<Parent />); expect(plainRenders).toBe(1); rerender(<Parent />); rerender(<Parent />); // A stable callback changes nothing here: a non-memoized component // re-renders whenever its parent does, regardless of prop stability. expect(plainRenders).toBe(3);});
test('the same stable callback DOES stop a re-render once the child is wrapped in React.memo', () => { memoRenders = 0; function Parent() { const [, setTick] = useState(0); const onSelect = useCallback(() => setTick((t) => t + 1), []); return <MemoRow onSelect={onSelect} />; } const { rerender } = render(<Parent />); expect(memoRenders).toBe(1); rerender(<Parent />); rerender(<Parent />); expect(memoRenders).toBe(1); // memo + stable reference — the pairing works});✓ useCallback alone does not stop a re-render — the child must be memoized too✓ the same stable callback DOES stop a re-render once the child is wrapped in React.memo
Test Files 1 passed (1) Tests 2 passed (2)useCallback and React.memo are inseparable: one supplies a stable reference, the other is what actually acts on it. Using either alone accomplishes nothing.
Children as props: skipping re-renders without memoizing anything
Section titled “Children as props: skipping re-renders without memoizing anything”The previous lesson mentioned “lift content by composition” as a structural fix — here is the proof. When a component holds its own state and renders {children} it received as a prop, that children value is the exact same element reference on every one of the component’s own re-renders (its parent created that element once; the component holding the ticking state never recreates it). React’s reconciler can bail out of that subtree entirely without even calling the child component’s function again — no React.memo, no useMemo, no useCallback involved.
import { useState, type ReactNode } from 'react';import { render, screen, fireEvent, cleanup, act } from '@testing-library/react';import { afterEach } from 'vitest';
afterEach(cleanup);
let expensiveRenders = 0;
function Expensive() { expensiveRenders++; return <p>expensive</p>;}
// ❌ Ticker builds `<Expensive />` itself, inside its own render — every// tick re-creates it, so it re-renders along with Ticker.function TickerBuildsItsOwnChild() { const [tick, setTick] = useState(0); return ( <div> <button onClick={() => setTick((t) => t + 1)}>tick: {tick}</button> <Expensive /> </div> );}
// ✅ Ticker receives `children` from its OWN parent instead — its local// `tick` state changing does not touch that prop at all.function Ticker({ children }: { children: ReactNode }) { const [tick, setTick] = useState(0); return ( <div> <button onClick={() => setTick((t) => t + 1)}>tick: {tick}</button> {children} </div> );}
test('Expensive built directly inside the ticking component re-renders on every tick', () => { expensiveRenders = 0; render(<TickerBuildsItsOwnChild />); expect(expensiveRenders).toBe(1);
act(() => { screen.getByText(/tick: 0/).click(); }); act(() => { fireEvent.click(screen.getByText(/tick: 1/)); });
expect(expensiveRenders).toBe(3); // Expensive re-renders with every tick});
test('the same component passed in as `children` does NOT re-render when the wrapper\'s OWN state ticks', () => { expensiveRenders = 0; render( <Ticker> <Expensive /> </Ticker>, ); expect(expensiveRenders).toBe(1);
act(() => { screen.getByText(/tick: 0/).click(); }); act(() => { fireEvent.click(screen.getByText(/tick: 1/)); });
// Ticker re-rendered twice (its button label changed), but the `children` // prop it holds is the exact same element reference both times — React // bails out of that subtree entirely, so Expensive never re-renders. expect(expensiveRenders).toBe(1);});✓ Expensive built directly inside the ticking component re-renders on every tick✓ the same component passed in as `children` does NOT re-render when the wrapper's OWN state ticks
Test Files 1 passed (1) Tests 2 passed (2)This trick only helps against re-renders caused by the component holding children. If whatever created that children element re-renders itself (its own ancestor), a new element is created and passed down again, and the usual propagation rules from the previous lesson apply.
useMemo for expensive derivations — and letting the compiler do it for you
Section titled “useMemo for expensive derivations — and letting the compiler do it for you”useMemo earns its keep on a genuinely expensive computation — sorting or filtering thousands of items, heavy math, building a derived data structure. The React Compiler changes who is expected to write that useMemo: see Memoization & the Compiler for how the compiler reads a plain, un-memoized component and inserts the equivalent caching itself, at a finer grain than most hand-written code — that lesson shows the compiler’s real output on exactly this kind of derivation.
Once the compiler is enabled on a codebase, the guidance flips:
- Do not hand-write
useMemo/useCallbackby default — the compiler handles the common cases. - Do keep your components pure and follow the Rules of Hooks — that is what lets the compiler optimize safely.
- Reserve manual memoization for the rare case the compiler cannot handle or a profiler still flags.
When manual memoization actually helps (today)
Section titled “When manual memoization actually helps (today)”Until the compiler is universal, hand-memoization is still worth it for a few specific cases:
- A genuinely expensive computation (sorting/filtering thousands of items, heavy math) →
useMemo. - A value/function passed to a memoized child or an effect dependency where a new reference would cause extra renders or effect runs →
useCallback/useMemo. - A large, frequently-bypassed subtree →
React.memo.
Outside those, memoizing usually costs more (memory + comparison + noise) than it saves. Measure.
When React.memo is a net loss
Section titled “When React.memo is a net loss”React.memo is not free: every render still pays for the shallow prop comparison, and the memoized component now holds onto its last rendered output in memory. Wrapping a component in memo is a net loss when:
- The component is cheap to render anyway. Comparing props costs about the same as just re-rendering a trivial component — you paid a cost and got nothing back.
- The props change on almost every render regardless. If a memoized component’s props are realistically never stable (e.g. it always receives fresh derived data), the comparison runs every time and never once pays off — it is pure overhead.
- Nobody kept the child’s own props stable. As shown above,
React.memoon a component whose caller keeps passing inline objects/functions does the comparison work and then re-renders anyway — worse than not memoizing, since you now pay for the failed comparison and the full re-render.
The rule of thumb: reach for React.memo on a component you have profiled as both expensive and prop-stable — not reflexively on everything.
Mental Model
Section titled “Mental Model”Think of React.memo as a locked gate in front of a component: it only opens (skips the re-render) if every prop presented at it is === to what it saw last time. useMemo/useCallback are the keys that make a value or function actually stay the same object across renders so the gate can recognize it. Passing children down from further up the tree is a third, gate-free path: the value never gets re-forged in the first place, so there is nothing for a “changed prop” check to even trip on.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
useMemo/useCallback + React.memo (paired) | Skips re-rendering a genuinely expensive, stable-props subtree | Both sides must be applied correctly; miss one prop and the pairing does nothing |
| Children as props | No memoization API at all; structural, cannot be “used wrong” | Only protects against the holding component’s own re-renders, not its ancestors’ |
| Let the React Compiler do it | Finer-grained, always in sync with the code | Requires the toolchain; only covers statically-verifiable code |
| Manual memoization everywhere | None | Comparison cost + memory on every render, for components that were often already cheap |
Common Misconceptions
Section titled “Common Misconceptions”- “Wrapping a component in
React.memoalways helps.” Proven above: if the props are not referentially stable, or the component was already cheap,memoadds a comparison cost and delivers no benefit — sometimes a net loss. - “
useCallbackalone stops child re-renders.” Proven above: a stable reference does nothing unless the receiving component is itself wrapped inReact.memoto actually check it. - “Passing
childrendown is just a syntax preference.” It is a real, measurable re-render optimization — proven above — that works with zero memoization APIs, because the element reference never changes in the first place. - “The compiler makes all of this obsolete, so I don’t need to understand it.” The compiler only transforms code it can statically verify follows the Rules of React; understanding why memoization works is still what tells you whether a specific piece of code is one the compiler can safely help with.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that reaches for useMemo/useCallback/React.memo, check for:
- Is a
React.memochild receiving an inline object, array, or function literal from its caller? That silently defeats the memoization every render (proven above). - Is
useCallbackused on a callback passed to a component that is not itself wrapped inReact.memo? The stable reference accomplishes nothing there. - Could the “expensive child” simply be restructured as
childrenpassed down from higher up, instead of reaching foruseMemo/React.memoat all? - Is
React.memoapplied to a component that is cheap to render and/or whose props are realistically never stable — pure overhead with no payoff? - Does the project have the React Compiler configured, or is the code hand-writing memoization the compiler would otherwise handle more precisely? (See Memoization & the Compiler.)
🐛 Spot the bug
const ExpensiveRow = React.memo(function ExpensiveRow({ item, onSelect }) {return <li onClick={() => onSelect(item.id)}>{item.label}</li>;});
function List({ items }) {function handleSelect(id) { console.log("selected", id);}return ( <ul> {items.map((item) => ( <ExpensiveRow key={item.id} item={item} onSelect={handleSelect} /> ))} </ul>);}Show the answer
Two separate problems stack here. First, onSelect={() => onSelect(item.id)} inside ExpensiveRow itself is irrelevant to the memo check (it runs after the prop comparison already passed or failed) — but handleSelect re-declared as a fresh function inside List on every render is exactly the bug: List passes a brand-new onSelect reference to every ExpensiveRow on every render, so React.memo’s shallow comparison fails every time and the memo never pays off, proven by the identical pattern in this lesson’s first test (renderCount 1 → 3, not 1 → 1). The fix is const handleSelect = useCallback((id) => console.log("selected", id), []) in List, paired with ExpensiveRow already being memoized — exactly the pairing this lesson proves is required.