Skip to content

Memoization in Practice

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 }) { /* ... */ });

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 reference defeats React.memo
// ❌ 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”
src/__tests__/inlineFunctionBreaksMemo.test.tsx
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)
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>
);
}
Babel 7.29.9 (jsdelivr) · React 19.3.0 dev build (esm.sh, pinned)
ComponentRendersLast 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.

src/__tests__/useCallbackNeedsMemoizedChild.test.tsx
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.

src/__tests__/childrenAsPropsStopsRerenders.test.tsx
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/useCallback by 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.

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.memo on 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.

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.

ChoiceBenefitCost
useMemo/useCallback + React.memo (paired)Skips re-rendering a genuinely expensive, stable-props subtreeBoth sides must be applied correctly; miss one prop and the pairing does nothing
Children as propsNo 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 itFiner-grained, always in sync with the codeRequires the toolchain; only covers statically-verifiable code
Manual memoization everywhereNoneComparison cost + memory on every render, for components that were often already cheap
  • “Wrapping a component in React.memo always helps.” Proven above: if the props are not referentially stable, or the component was already cheap, memo adds a comparison cost and delivers no benefit — sometimes a net loss.
  • “useCallback alone stops child re-renders.” Proven above: a stable reference does nothing unless the receiving component is itself wrapped in React.memo to actually check it.
  • “Passing children down 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.

When reviewing generated code that reaches for useMemo/useCallback/React.memo, check for:

  • Is a React.memo child receiving an inline object, array, or function literal from its caller? That silently defeats the memoization every render (proven above).
  • Is useCallback used on a callback passed to a component that is not itself wrapped in React.memo? The stable reference accomplishes nothing there.
  • Could the “expensive child” simply be restructured as children passed down from higher up, instead of reaching for useMemo/React.memo at all?
  • Is React.memo applied 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.

What does useCallback cache?
Why does passing an inline style={{ color: "red" }} to a React.memo child defeat the memo?
After three renders of Parent, what is renderCount on Row?
const Row = memo(function Row({ onSelect }) { renderCount++; return <li onClick={onSelect} />; });
function Parent() {
  const [, setTick] = useState(0);
  return <Row onSelect={() => setTick(t => t + 1)} />;
}
If a callback is wrapped in useCallback but the component receiving it is a plain function component (not wrapped in React.memo), does the re-render stop?
Why does passing a component in as children avoid re-renders, without any useMemo/useCallback/React.memo?
When is wrapping a component in React.memo most likely a net loss?