5.2 — Memoization & React Compiler¶
Module 5 · Lesson 2 · 🟡 Intermediate · ~40 min
What you'll learn¶
- When
memo,useMemo, anduseCallbackpay for themselves — and when they're net negative - The prop‑stability rules that make memoization actually work
- What React Compiler does, what it doesn't, and how to adopt it safely
- Measuring whether memoization helped
The cost model¶
Memoization is a trade: you spend a comparison (and memory) to avoid a render.
memo() saves you: the child's render + its subtree's renders
memo() costs you: a shallow props comparison, every parent render
+ memory for the retained result
+ code complexity
Worth it when: render cost ≫ comparison cost, AND props are usually stable
Not worth it when: the component is trivial, OR props change every render anyway
The failure mode that makes memoization useless:
// ❌ memo does NOTHING here — `onSelect` is a new function every parent render,
// so the comparison always fails and you pay for it plus the render.
const ProductCard = memo(function ProductCard({ product, onSelect }: Props) {
return <article onClick={() => onSelect(product.id)}>…</article>;
});
function ProductGrid({ products }: { products: Product[] }) {
const [selected, setSelected] = useState<string | null>(null);
return products.map((p) => (
<ProductCard
key={p.id}
product={p}
onSelect={(id) => setSelected(id)} // 💀 new function every render
/>
));
}
// ✅ Stable callback identity
function ProductGrid({ products }: { products: Product[] }) {
const [selected, setSelected] = useState<string | null>(null);
const onSelect = useCallback((id: string) => setSelected(id), []);
return products.map((p) => (
<ProductCard key={p.id} product={p} onSelect={onSelect} />
));
}
Every prop must be stable for memo to work. One unstable prop defeats the whole thing:
| Prop type | Stable? | Fix |
|---|---|---|
| Primitives (string, number, boolean) | ✅ Always | — |
Object literal { a: 1 } |
❌ New identity each render | useMemo, or pass primitives |
Array literal [1, 2] |
❌ | useMemo, or move outside the component |
| Inline function | ❌ | useCallback |
| JSX as a prop | ❌ | useMemo, or use children |
| Value from a Server Component | ✅ Stable per navigation | — |
| Context value object | ❌ unless memoized | useMemo the provider value |
Where memoization pays on a commerce site¶
✅ Worth it: product cards in a long grid¶
const ProductCard = memo(
function ProductCard({ product, onQuickView }: ProductCardProps) {
return (
<article className="group">
<Image src={product.image} alt={product.name} width={400} height={533} sizes="…" />
<h3>{product.name}</h3>
<PriceDisplay value={product.formattedPrice} />
<button onClick={() => onQuickView(product.id)}>Quick view</button>
</article>
);
},
// Custom comparison: only re-render when fields we actually display change.
// Use this sparingly — it's easy to get wrong and cause stale UI.
(prev, next) =>
prev.product.id === next.product.id &&
prev.product.formattedPrice === next.product.formattedPrice &&
prev.product.image === next.product.image &&
prev.onQuickView === next.onQuickView,
);
48 cards × 7.6 ms = 365 ms saved on every filter toggle that doesn't change the product set. This is the highest‑value memoization on a commerce site.
Careful with custom comparators. If you forget a field, the UI goes stale in a way that's hard to debug. Prefer restructuring props so the default shallow comparison works.
✅ Worth it: genuinely expensive computation¶
// Faceted filter counts over 2,000 products: ~40ms
const facetCounts = useMemo(
() => computeFacetCounts(products, activeFilters),
[products, activeFilters],
);
❌ Not worth it: trivial derivations¶
// ❌ The memo machinery costs more than the work
const total = useMemo(() => price * quantity, [price, quantity]);
const label = useMemo(() => `${count} items`, [count]);
const isEmpty = useMemo(() => items.length === 0, [items]);
// ✅ Just compute it
const total = price * quantity;
❌ Not worth it: components that always get new props¶
// ❌ `items` is a new array every render — memo can never hit
const CartSummary = memo(function CartSummary({ items }: { items: CartItem[] }) { … });
<CartSummary items={cart.items.filter((i) => i.active)} /> // new array each time
Fix the source (memoize the filter, or move it into the child), or drop the memo.
Context value memoization¶
The most common context performance bug: a new value object on every provider render.
// ❌ New object every render → every consumer re-renders, every time
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
return (
<CartContext.Provider value={{ items, isOpen, setItems, setIsOpen }}>
{children}
</CartContext.Provider>
);
}
// ✅ Memoized value — but note this still re-renders all consumers when
// ANY field changes. Splitting the context is the better fix ([5.3]).
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const [isOpen, setIsOpen] = useState(false);
const value = useMemo(
() => ({ items, isOpen, setItems, setIsOpen }),
[items, isOpen],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
React Compiler¶
React Compiler (1.0 released in 2025) automatically memoizes components and values at build time,
based on a static analysis of your code. It applies memoization more precisely than humans
typically do — including partial memoization within a component that hand‑written useMemo can't
express.
Setup¶
Adopt incrementally on a large codebase:
const config: NextConfig = {
experimental: {
reactCompiler: {
compilationMode: 'annotation', // only components with "use memo"
},
},
};
Or opt in by directory:
What it does and doesn't do¶
| Does | Doesn't |
|---|---|
| Auto‑memoize component outputs | Fix a bad state architecture |
| Auto‑memoize expensive derivations | Reduce your bundle size |
| Stabilize callback and object identities | Virtualize long lists |
| Skip re‑renders when inputs are unchanged | Make a genuinely expensive computation cheap |
Reduce the need for manual memo/useMemo/useCallback |
Help if the data itself changes every render |
It is not a substitute for Modules 3 and 4. A 700 KB bundle with a monolithic cart context will still be slow with the compiler on. The compiler makes correct code faster; it doesn't fix architecture.
The prerequisite: Rules of React¶
The compiler only helps where your code follows the Rules of React. It bails out (safely, leaving the component unoptimized) when it can't prove safety.
# Find violations before adopting — this is the real work of adoption
npx eslint --plugin react-hooks --rule 'react-hooks/rules-of-hooks: error' .
// eslint.config.js — the compiler's own lint rule flags code it can't optimize
import reactHooks from 'eslint-plugin-react-hooks';
export default [{
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Surfaces patterns the compiler must bail out on
'react-hooks/react-compiler': 'warn',
},
}];
Common bail‑out causes in commerce codebases:
// ❌ Mutating props or state directly — the compiler can't reason about this
function ProductList({ products }: Props) {
products.sort((a, b) => a.price - b.price); // mutates the prop!
return products.map(/* … */);
}
// ✅
function ProductList({ products }: Props) {
const sorted = [...products].sort((a, b) => a.price - b.price);
return sorted.map(/* … */);
}
// ❌ Reading a ref during render
function Card() {
const ref = useRef(0);
return <div>{ref.current}</div>; // ref reads belong in effects/handlers
}
// ❌ Conditional hooks
if (isLoggedIn) { const [x] = useState(0); }
Verifying it's working¶
# Check which components the compiler actually optimized
npx react-compiler-healthcheck --src "app/**/*.tsx" --src "components/**/*.tsx"
Output tells you the share of components successfully compiled and lists bail‑outs with reasons. Aim for >90% before claiming the compiler is "adopted" — a codebase where half the components bail out gets half the benefit and all of the false confidence.
Adoption plan¶
Week 1 Enable the lint rules. Fix violations. Do NOT enable the compiler yet.
Week 2 Run the healthcheck. Fix the top bail-out causes.
Week 3 Enable in annotation mode on one high-traffic component tree (the PLP grid).
Measure INP for that page, A/B if you can.
Week 4 Expand by directory. Re-measure at each step.
Week 5+ Enable globally. Remove hand-written memoization only where the compiler
demonstrably covers it — and only with a profile to prove it.
Don't rip out manual memoization on day one. Redundant useMemo alongside the compiler is
harmless; removing it before verifying coverage is how you ship a regression.
Measuring whether memoization helped¶
Memoization is uniquely prone to cargo‑culting. Verify every time:
// A dev-only render counter — crude, effective, and it tells you the truth
function useRenderCount(name: string) {
const count = useRef(0);
count.current++;
if (process.env.NODE_ENV === 'development') {
console.log(`[render] ${name}: ${count.current}`);
}
}
Better, the actual protocol:
- React Profiler, record the interaction, note total commit duration.
- Add the memoization.
- Record again. Note the new duration.
- If it didn't drop by a measurable amount, revert it. You added complexity for nothing.
Before memo: filter toggle → 412ms commit, 48 ProductCards rendered
After memo: filter toggle → 47ms commit, 0 ProductCards rendered ✅ keep
Before memo: cart open → 38ms commit
After memo: cart open → 37ms commit ❌ revert
useMemo for referential stability vs computation¶
Two different reasons to use useMemo, often confused:
// Reason 1: avoid an expensive computation
const facets = useMemo(() => computeFacets(products), [products]); // 40ms saved
// Reason 2: keep a reference stable so a downstream memo/effect works
const config = useMemo(() => ({ currency, locale }), [currency, locale]);
useEffect(() => { track(config); }, [config]); // without useMemo, fires every render
Reason 2 is valid even when the computation is trivial — the point is identity, not cost. This is the case people wrongly "optimize away", causing an effect to run on every render.
The compiler handles both automatically, which is the strongest argument for adopting it.
Common mistakes¶
| Mistake | Consequence |
|---|---|
memo with unstable props |
Pay the comparison, still re‑render |
useMemo on trivial expressions |
Complexity, no benefit, more allocations |
| Custom comparator that misses a field | Stale UI, very hard to debug |
| Unmemoized context value | Every consumer re‑renders on every provider render |
| Expecting the compiler to fix architecture | Still slow, now with more confidence |
| Enabling the compiler with many bail‑outs | Partial coverage, false sense of completion |
| Removing manual memoization on compiler day one | Silent regressions |
| Never measuring | Cargo‑cult memoization forever |
Lab 5.2 — Memoization audit¶
- Find unstable props. In the React Profiler, look for components whose "why did this render" says "Props changed" with a prop you expected to be stable. List them.
- Fix the sources:
useCallbackfor handlers,useMemofor object/array props, or restructure to pass primitives. - Memoize the highest‑count component on your list page. Measure the commit duration before and after. Keep it only if it dropped.
- Audit context values:
rg -n "\.Provider value=\{\{" app components— every hit is an unmemoized object literal. - Run the compiler healthcheck. Record the bail‑out rate and the top three causes.
- Enable the compiler in annotation mode on one component tree and measure INP.
- Remove any memoization that measurement showed to be useless.
Checklist¶
- Every
memo'd component has verifiably stable props - Context provider values are memoized (or the context is split — see 5.3)
- No
useMemoon trivial expressions - Custom comparators avoided unless necessary and tested
- Rules‑of‑React lint rules enabled and clean
- Compiler healthcheck > 90% before claiming adoption
- Every memoization was measured before and after
- Manual memoization removed only after verifying compiler coverage
Next: 5.3 State architecture