Skip to content
Chapter 26Lesson 3

Memoization as escape hatch

The narrow cases where manual React memoization still earns its place once the React Compiler is on.

The compiler is on. You turned it on in the previous lesson, “The React Compiler,” and stopped wrapping derived values in useMemo, handlers in useCallback, and components in memo — the compiler does that now. So is manual memoization gone? Not entirely. A narrow set of cases still needs it by hand; a much larger pile of older performance habits should be deleted. This lesson teaches you to look at any useMemo, useCallback, or memo in a 2026 codebase and decide on sight: keep it with a comment saying why, or delete it. One rule gates every reach — measure first.

Pre-compiler, React performance work was a discipline: wrap every derived value in useMemo, every handler in useCallback, and every leaf component in memo, on reflex. You wrapped not because the value in front of you was expensive, but because some value somewhere might be, and wrapping was cheap insurance. Across a codebase, that bookkeeping cost as much effort as the features.

The compiler ends it by auto-memoizing the cases the discipline existed for. Manual memoization becomes an escape hatch : a deliberate opt-out for a specific guarantee the compiler cannot give you.

On the left is how a 2020 codebase distributes memoization; on the right is the 2026 default.

2020 memoization as discipline
useMemo on every derived value
useCallback on every handler
memo on every leaf component
dynamic() for anything "heavy"
<Suspense> around everything
2026 escape hatch
reach only on a measured or contractual cause + a one-line comment naming it
everything else: no wrapper
Same surface area, opposite defaults: the right column is nearly empty.

The four cases where manual memoization still earns its weight

Section titled “The four cases where manual memoization still earns its weight”

These four cases are the whole set of reasons left. A manual memoization that isn’t one of them is dead weight; delete it. The rest of this section walks each case with its code and required comment inline.

Before you reach for a case, walk the decision below. The order of the questions is what keeps a considered judgment from collapsing into a reflex, so work through it the way you’d question your own instinct in a pull request.

Should this be a manual memo?

Every keep branch shares one thing: a concrete, nameable cause. Here are the four in turn.

Case 1: a stable reference an effect depends on

Section titled “Case 1: a stable reference an effect depends on”

An effect re-runs whenever a value in its dependency array changes identity. For an object or function, identity changes when the reference changes, and a fresh object or function is built on every render. So an effect that depends on an options object rebuilt each render re-fires every render, even when nothing inside the object changed.

Picture an SDK client that opens a subscription and tears it down whenever its options reference changes:

PriceTicker.tsx
const PriceTicker = ({ symbol }: { symbol: string }) => {
const [price, setPrice] = useState<number | null>(null);
// stable ref: the SDK re-subscribes whenever options identity changes
const options = useMemo(() => ({ symbol, throttleMs: 500 }), [symbol]);
useEffect(() => {
const sub = priceFeed.subscribe(options, setPrice);
return () => sub.unsubscribe();
}, [options]);
return <span>{price ?? ''}</span>;
};

The comment is part of the code. // stable ref: the SDK re-subscribes whenever options identity changes tells the next reader why this useMemo exists and which effect breaks without it.

The compiler often memoizes that object too, but here the effect’s correctness depends on the reference staying stable, and you don’t want correctness to hinge on whether the compiler’s analysis happened to line up with your dependency array. When an effect must not re-fire, make the guarantee explicit. That’s the line between an optimization, which is the compiler’s job, and a contract, which is yours.

In review, flag a useMemo like this with no comment naming the effect it serves: the reviewer can’t tell load-bearing stabilization from leftover ceremony.

Case 2: a library that reads by reference equality

Section titled “Case 2: a library that reads by reference equality”

Same idea as case 1, referential stability, at a different boundary, and the most common real reach you’ll make in a 2026 app.

Some libraries re-run or re-render when a value’s reference changes: they compare by identity, not content. A form library may re-register a field given a new options reference; a charting library may rebuild when you hand it a fresh options object; a store selector may recompute when its input identity churns. The compiler can’t see inside these libraries, so it won’t necessarily stabilize what they need.

So you stabilize the value yourself at the integration point, the line where your code hands it across the boundary into the library:

RevenueChart.tsx
const RevenueChart = ({ points }: { points: Point[] }) => {
// chart library reads this by reference equality — rebuilds on a new ref
const options = useMemo(
() => ({ responsive: true, scales: { y: { beginAtZero: true } } }),
[],
);
const chart = useChart({ data: points, options });
return <Chart instance={chart} />;
};

The comment carries the library’s name and the constraint: // chart library reads this by reference equality — rebuilds on a new ref. That constraint lives in the library’s contract, not your code, so the comment is the only place the next reader can learn it. Skip it and they rediscover the quirk the hard way: delete the useMemo, watch the chart flicker every render, reverse-engineer why.

You’ll meet libraries with this property later, react-hook-form in the forms unit and Zustand later still. The pattern is what matters now: when a library’s contract demands a stable reference, the integration point is where you provide it and the comment is how you justify it.

Case 3: a measured expensive computation the compiler skipped

Section titled “Case 3: a measured expensive computation the compiler skipped”

This case alone is gated strictly by measurement.

Sometimes a component does genuinely heavy work during render: sorting a large list, tokenizing a document, building a fuzzy-match index over thousands of records. If its inputs don’t change between renders, recomputing it is pure waste. The compiler memoizes a great deal, but it declines a computation it can’t prove pure, or whose inputs are too tangled for its static analysis. Then the work runs every render, and the Profiler shows it at the top of the flame graph on renders where its inputs were identical.

Only then do you reach for useMemo, keyed on the real inputs:

useRankedMatches.ts
// Profiler: rankMatches ran 18ms on every keystroke, inputs unchanged
const ranked = useMemo(
() => rankMatches(items, query),
[items, query],
);

The comment names the number: // Profiler: rankMatches ran 18ms on every keystroke, inputs unchanged. A future reader, or you in six months, knows this wasn’t a guess.

Watch the trap, because most computations that look expensive are cheap. Concatenating a name, formatting a date, mapping fifty items, filtering a dropdown: these run in microseconds, far faster than the render React already does. Wrapping them in useMemo adds the cost of storing and comparing the previous inputs and buys nothing back. Only a Profiler reading of real, repeated cost earns this case. A useMemo added because the function has rank in its name is the reflex this lesson cuts: case 3 has a price of admission, and the price is a measured number.

Case 4: a hot-path leaf that must not re-render

Section titled “Case 4: a hot-path leaf that must not re-render”

This is the rarest reach, and the one most likely to be a smell.

Some components are expensive to re-render on their own: a row in ten thousand virtualized items, a component that redraws to a <canvas>, a chart that repaints a heavy visualization. For one of these, on a path the Profiler has shown to be hot, you may want a hard guarantee: skip the re-render unless specific props change. React.memo gives you that. Wrap the component, and React compares its props and skips the re-render when they’re unchanged.

Row.tsx
// Profiler: 1.2k rows; row repaint dominated each scroll frame
const Row = memo(function Row({ item }: { item: ListItem }) {
return <ChartCell value={item.value} label={item.label} />;
});

React.memo takes an optional second argument, a comparator that decides whether two sets of props count as equal. You’ll see it in older code, so recognize the shape, memo(Row, (prev, next) => prev.item.id === next.item.id), but treat it as a warning sign. A custom comparator almost always means the props are shaped wrong: you pass an object where the component cares about one field, then hand-write a comparison to ignore the rest. The fix is upstream, pass the primitive the component needs, <Row id={item.id} value={item.value} />, and the default shallow comparison just works. The comparator treats the symptom; restructuring the props removes the cause.

Keep this in proportion. You don’t have virtualized lists or canvas rendering yet, so the goal isn’t to wield memo today. It’s to recognize it as a targeted instrument applied at one measured boundary, the opposite of the blanket memo reflex you’re about to learn to delete.

This is the one place in the course you write these three by hand, for the rare case that needs them. The three tabs are three forms of one idea.

// same ref while deps unchanged by Object.is
const memoized = useMemo(() => compute(a, b), [a, b]);

Runs the function and caches the result, returning the same reference on later renders as long as every dependency is unchanged by Object.is. For a plain derived value the compiler does this better and you write nothing. What’s left is reference stability for a downstream consumer: an effect, a library, or a measured-expensive computation (cases 1, 2, and 3).

Wrapping reflexes to delete from legacy code

Section titled “Wrapping reflexes to delete from legacy code”

The four cases above are what to keep; this section is what to cut, and in legacy code the cut is the bigger pile. Three of these are pure memoization you can delete today. The last two are bundle and loading habits you haven’t formally learned yet, so for those the job is only to recognize the reflex and know it’s wrong by default.

  • useMemo on every value. The largest cleanup opportunity in legacy React. Wrapping every derived value adds noise and can interfere with the compiler’s own analysis. const fullName = firstName + ' ' + lastName; needs no wrapper, and never did.
  • useCallback on every handler. Same reflex, same cut. onClick={() => setOpen(true)} is fine bare. The compiler stabilizes a handler at the boundaries where stability matters, so you don’t pre-empt it everywhere.
  • memo on every component. The compiler memoizes JSX subtrees automatically, so wrapping every leaf buys nothing. memo is now the targeted instrument of case 4, not a blanket wrapper.
  • Premature next/dynamic. The old reflex was to dynamic()-load anything that felt heavy. The real trigger is narrow: a measured client bundle carrying a component most users never render, like a modal opened in 5% of sessions. The full workflow lives in the performance-vigilance unit; for now, recognize that “feels heavy” is not the trigger.
  • Blanket <Suspense> boundaries. The same reflex in a different form. Boundaries belong at meaningful UX seams, such as a route segment or a region with its own loading state, which you’ll place precisely in the Next.js unit. Sprinkling them everywhere just fragments the loading experience.

Three of those are memoization, and it’s worth seeing that the blanket version buys nothing rather than taking the claim on faith. Below, two versions of the same component tree react to the same trigger: a state change in an unrelated sibling. One wraps every node in memo; the other runs the compiler with no manual memo at all. Flip between them and watch which boxes light up.

Sidebar toggles its own state — which boxes re-render?

Both columns light the same boxes. That’s the proof: the blanket memo is dead weight, since the compiler delivers identical render behavior with none of the wrapping. When you find blanket memo in legacy code, it’s safe to delete.

The dangerous lookalike: useMemo is not a cache

Section titled “The dangerous lookalike: useMemo is not a cache”

Measure, then memoize: the workflow that gates every reach

Section titled “Measure, then memoize: the workflow that gates every reach”

The four cases share one discipline, the inverse of the 2020 instinct. The order is fixed:

  1. Compiler on. If it isn’t, almost everything you’re about to hand-tune is already handled, so turn it on first.
  2. Run the Profiler on a real interaction. Not a guess about what’s slow, but a recording of an actual user action: a keystroke in a search box, a scroll, a tab switch.
  3. Find the actual hot spot. Look for the specific failure: a component rendering when its props didn’t change, or a computation running on inputs identical to last render.
  4. Apply the targeted escape hatch, with a comment. One wrapper, at the one boundary the Profiler pointed at, carrying the reason.

Never run that order in reverse. Memoization before measurement is the old reflex dressed up to look deliberate.

The Profiler is a panel in React DevTools that records renders and shows which components rendered, why, and how long each took. Record an interaction, then scan for components that rendered when their inputs didn’t change. That signal gates cases 3 and 4, and nothing in those cases happens without it. The full craft, reading flame graphs and running the complete loop, lives in the performance-vigilance unit later.

One last point about the comment: every manual memoization that’s left carries a one-line comment naming its cause. “SDK requires a stable ref.” “Profiler: 18ms in ranking.” “Chart library reads by reference.” That comment is the entire difference between a justified escape hatch and 2020 noise, and it’s the first thing a reviewer checks for. The two snippets below are the same useMemo line; the only difference is that one survives review.

const ranked = useMemo(() => rankMatches(items, query), [items, query]);

No comment, no cause. A reviewer can’t tell whether this stabilizes something real or is leftover ceremony, so the safe call is to delete it and let the compiler work. With no justification on the line, deletion is the correct default.

You’ll inherit codebases full of discipline-era wrapping, and “delete your memoization” is a slogan, not a plan. Deleting blind is risky: removing a manual memo can subtly change what the compiler produces, and an effect that was quietly relying on a stable reference starts over-firing the instant you pull its useMemo. So the cleanup runs file by file, gated by tests and the Profiler, on top of the compiler’s annotation mode from the previous lesson.

  1. Enable annotation mode. Set compilationMode: 'annotation' so the compiler only touches files that opt in. (The wiring is in the previous lesson, “The React Compiler.”)

  2. Opt one small, well-understood file in. Add the 'use memo' directive to a single file you know well, not the most tangled one in the codebase.

  3. Delete that file’s wrapping, then verify. Remove its manual useMemo / useCallback / memo, run the tests, and profile the interaction it participates in. Confirm the behavior is unchanged before moving on.

  4. Expand one file at a time. Repeat the opt-in, delete, verify loop file by file. Each file is a small, reversible step.

  5. Flip to full coverage. Once enough of the codebase is migrated and trusted, switch to whole-project compilation with reactCompiler: true.

  6. Final pass: keep only the four cases. Delete the remaining ceremony. What survives is exactly the four cases, each carrying its one-line comment naming its cause.

Keep or delete: judge the cause, not the API

Section titled “Keep or delete: judge the cause, not the API”

The whole lesson comes down to one skill: looking at a manual memoization and deciding keep or delete by reading its cause, not its API. Every wrapper below uses one of the three APIs you just learned, and on its own the API tells you nothing. Sort each into the bucket where it belongs.

Sort each manual memoization into keep-with-a-comment or delete. Read the cause, not the API — every API shows up in both buckets. Drag each item into the bucket it belongs to, then press Check.

Keep — earns its weight A measured or contractual cause the compiler can't serve
Delete — 2020 reflex No cause; the compiler already covers it
useMemo on the options object an SDK re-subscribes on whenever its reference changes
useCallback handed to a react-hook-form field that reads it by reference equality
useMemo on a sort the Profiler shows at 20ms on every keystroke, inputs unchanged
React.memo on a virtualized list row the Profiler flagged as dominating each scroll frame
useMemo(() => firstName + ' ' + lastName, [firstName, lastName])
useCallback on an onClick attached to a single button no effect or library reads
React.memo on a leaf whose parent never re-renders, guarding against nothing
useMemo(() => fetch(url).then((r) => r.json()), []) — the not-a-cache trap

Sorting by cause rather than by API is the skill this lesson set out to build.

The React docs for each of these three APIs are unusually candid: every page opens by talking you out of reaching for it by default, which is the same shift this lesson is built on.