Skip to content
Chapter 26Lesson 3

Memoization as escape hatch

When manual React memoization still earns its place now that the React Compiler handles the rest.

The compiler is on. You turned it on in the previous lesson, “The React Compiler,” and the immediate consequence is that you stopped writing useMemo, useCallback, and memo everywhere. The wrapping that used to fill every React file is now the compiler’s job. That raises a fair question: is manual memoization gone for good? It isn’t. A narrow set of cases still calls for it by hand, and a much larger pile of older performance habits should now be deleted. This lesson draws the line between the two. By the end you’ll be able to look at any useMemo, useCallback, or memo in a 2026 codebase and decide on sight: either keep it with a comment that says why, or delete it. You’ll also know the one rule that gates every reach, which is to measure first.

Memoization went from discipline to escape hatch

Section titled “Memoization went from discipline to escape hatch”

The shift in mindset frames everything else, so start there.

Pre-compiler React performance work was a discipline. The rule you absorbed from tutorials and pull-request feedback was to wrap every derived value in useMemo, every handler in useCallback, and every leaf component in memo, and to try never to re-render anything you didn’t have to. The wrapping was constant. You learned to write it as a reflex, not because the particular value in front of you was expensive, but because some value somewhere might be, and wrapping it was cheap insurance. Multiply that across a codebase and you spend as much effort on memoization bookkeeping as on the actual feature.

The compiler ends that. As you saw in the last lesson, it auto-memoizes the five cases that mattered: derived values, object and array literals passed as props, callbacks defined inside components, the value you hand to a context provider, and JSX subtrees. That covers the overwhelming majority of what the discipline was for, so the role of manual memoization changes entirely. It becomes an escape hatch : a deliberate opt-out you reach for when you need a specific guarantee the compiler cannot give you.

The rest of the lesson builds on one rule, so hold onto it:

Everything below elaborates on that rule. The four cases are the causes that justify a wrapper, and the cleanup section covers every wrapper that has no cause.

The change is easier to see than to describe, so look at the contrast directly. 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's emptiness is the change.

If the claim that you can’t fully forget memoization sounds surprising, watch someone hit it in practice.

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 entire set of reasons left. If a manual memoization in your codebase isn’t one of them, it is dead weight and should be deleted. That’s a strong claim, and the rest of this section earns it by walking each case one at a time, with the code and the required comment shown inline.

First, though, the reasoning that sits above the four cases. When you feel the urge to wrap something, don’t jump straight to a case. Walk through a sequence of questions instead, since the order in which you ask them is what keeps a considered judgment from collapsing into a reflex. Work through the decision below the way you’d question your own instinct in a real pull request.

Should this be a manual memo?

Notice what every keep branch has in common: a concrete, nameable cause. “It might be slow” isn’t one. Here are the four cases in turn.

Case 1: a stable reference an effect depends on

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

The first reason to reach for a manual wrapper is referential stability for an effect.

Recall how effects work: an effect re-runs whenever a value in its dependency array changes identity. When one of those dependencies is an object or a function, “changes identity” means a new reference, and a fresh object or function is built on every render. So if an effect depends on an options object, and that object is rebuilt each render, the effect re-fires on every render, even when nothing inside the object actually changed.

That’s not a hypothetical. 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, not decoration. The line // stable ref: the SDK re-subscribes whenever options identity changes tells the next reader exactly why this useMemo exists and which effect would break without it. Without that line, the wrapper is indistinguishable from a reflex.

You might wonder why the compiler doesn’t just handle this. It often does memoize that object internally, 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 effect’s dependency array. When an effect must not re-fire, make the guarantee explicit rather than hope it was inferred. That’s the difference between an optimization, which is the compiler’s job, and a contract, which is yours.

Here is what to watch for in code review: a useMemo like this with no comment naming the effect that consumes it. The reviewer can’t tell a load-bearing stabilization from leftover 2020 ceremony, so they’re right to flag it.

Case 2: a library that reads by reference equality

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

This is the same idea as case 1, referential stability, but at a different boundary. It’s also the most common real reach you’ll make in a 2026 app.

Some libraries are built to re-run or re-render when a value’s reference changes: they compare by identity, not by content. A form library may re-register a field when its options object is a new reference, a charting library may rebuild the chart when you hand it a fresh options object every render, and a store selector may recompute when its input identity churns. The compiler can’t see inside these libraries, so it has no way to know that one of them reads a particular value by reference, and it won’t necessarily stabilize what the library needs stabilized.

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 outside your code, in the library’s contract, so the comment is the only place the next reader can learn it. Skip the comment and you leave them to rediscover the quirk the hard way: deleting the useMemo, watching the chart flicker on every render, and reverse-engineering why.

You’ll meet specific libraries with this property later. The form library react-hook-form in the forms unit and the state library Zustand later still both have surfaces that care about reference equality. Don’t worry about them now. The point here is the pattern: 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, and only this case, 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 the inputs to that work don’t change between renders, recomputing it is pure waste. The compiler memoizes a great deal, but it can decline to memoize a computation when it can’t prove the work is pure, or when the inputs are too tangled for its static analysis to reason about. When that happens, the expensive work runs on every render, and the Profiler will show it to you, sitting at the top of the flame graph on the renders where its inputs were identical.

Only then, and the order matters, 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 six months from now, can read that line and know this wasn’t a guess.

Watch out for the trap this case sets, because most computations that look expensive are cheap. Concatenating a name, formatting a date, mapping an array of fifty items, filtering a dropdown: these run in microseconds, far faster than the work React already does to render the result. Wrapping them in useMemo adds overhead, because the memo has to store the previous inputs and compare them, and it buys nothing in return. The only thing that earns this case is a Profiler reading showing real, repeated cost. A useMemo added because the function name has rank in it and that sounds slow is exactly the reflex this lesson cuts. We’ll come back to the measure-first workflow at the end. For now, hold onto the rule that 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.

There are components whose re-render is genuinely expensive on its own: a row in a list of 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 entirely unless these specific props change. That’s what React.memo gives you. 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 function that decides for itself whether two sets of props count as equal. You will 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. Reaching for a custom comparator almost always means the props are shaped wrong. You’re passing an object where the component only cares about one field, so you hand-write a comparison to ignore the rest. The better fix is upstream: pass the component the primitive it actually needs, <Row id={item.id} value={item.value} />, and the default shallow comparison just works, with no comparator required. The comparator treats the symptom, while restructuring the props removes the cause.

Keep this case 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 precise opposite of the blanket memo reflex you’re about to learn to delete.

Writing the three escape hatches: a compact reference

Section titled “Writing the three escape hatches: a compact reference”

This is the one place in the course you’ll see how to write these three by hand. Read it as a reference card for the rare reach, not a tutorial to memorize. If you find yourself reaching for this card often, the problem is the reaching, not the syntax. The three tabs below are three forms of the same idea.

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

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

Stop hand-tuning: the 2020 reflexes to delete

Section titled “Stop hand-tuning: the 2020 reflexes to delete”

The four cases are what to keep. This section is what to cut, and in a real legacy codebase the cut is the bigger pile by far. Three of these are pure memoization you can delete today. The last two reach into 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 single largest cleanup opportunity in legacy React. Wrapping every derived value adds noise, can interfere with the compiler’s own analysis, and trains your hands to write ceremony instead of code. 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 the handler at the boundaries where stability actually matters, so you don’t need to pre-empt it everywhere.
  • memo on every component. A pre-compiler default. The compiler memoizes JSX subtrees automatically, so wrapping every leaf in memo buys nothing. memo is now the targeted instrument of case 4, not a blanket wrapper.
  • Premature next/dynamic. The 2020 reflex was to lazy-load with dynamic() anything that felt heavy. 2026 defaults make that wrong by default. Server Components ship zero client JavaScript (you’ll see this in the Next.js unit), and the App Router splits your bundle per route automatically. The real trigger is narrow: a measured client bundle that includes a component most users never render, like a modal opened in 5% of sessions or a chart on a tab few people visit. The full workflow lives in the performance-vigilance unit later in the course. For now, recognize that “feels heavy” is not the trigger.
  • Blanket <Suspense> boundaries. Wrapping every slow-looking component in <Suspense> is the same reflex in a different form. Suspense boundaries belong at meaningful UX seams, such as a route segment or a region with its own loading state, and you’ll learn exactly where 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 just taking the claim on faith. Below, two versions of the same small component tree react to the same trigger, a state change in an unrelated sibling. One version wraps every node in memo; the other has the compiler on and 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 in the first version is pure dead weight, because the compiler delivers identical render behavior with none of the wrapping. When you find blanket memo in legacy code, this is what you’re looking at, and it’s safe to delete.

The dangerous lookalike: useMemo is not a cache

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

One misuse of useMemo costs enough to deserve its own warning.

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, and it’s 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 an actual recording of a 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 that were 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.

Don’t run that order in reverse. Memoization applied before measurement is the old reflex again, just dressed up to look deliberate.

A word on the tool, for recognition only. The Profiler is a panel in React DevTools that records renders and shows you which components rendered, why they rendered, and how long each took. The loop is simple: record an interaction, then scan for components that rendered when their inputs didn’t change. That’s the signal that gates cases 3 and 4. The full craft, reading flame graphs and running the complete record-and-analyze loop, lives in the performance-vigilance unit later in the course. For this lesson you only need to know the tool exists and that nothing in cases 3 and 4 happens without it.

One last point about the comment, stated now as a rule rather than a tip: 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 in code review it’s the thing a reviewer checks for. The two snippets below are the same useMemo line; the only difference is that one of them 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 do its job. With no justification on the line, deletion is the correct default.

You’ll inherit codebases full of the discipline-era wrapping, and “delete your memoization” is a slogan, not a plan. Here’s the plan. Deleting blind across the whole codebase is risky: removing a manual memo can subtly change what the compiler produces, and an effect that was quietly relying on a stable reference can start over-firing the instant you pull its useMemo. So the cleanup is deliberate, file by file, gated by tests and the Profiler. The compiler’s annotation mode, which turns it on for only the files that opt in, was covered in the previous lesson. Here’s the sequence you run on top of it.

  1. Enable the compiler in annotation mode. Set compilationMode: 'annotation' so the compiler only touches files that explicitly 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 understand 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 that file 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 across the codebase. What survives is exactly the four cases, and every survivor carries its one-line comment naming its cause.

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

If you sorted those by asking “what’s the cause?” rather than “which function is it?”, you have 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.