Skip to content

5.2 — Memoization & React Compiler

Module 5 · Lesson 2 · 🟡 Intermediate · ~40 min

What you'll learn

  • When memo, useMemo, and useCallback pay 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

npm i -D babel-plugin-react-compiler
// next.config.ts
const config: NextConfig = {
  experimental: {
    reactCompiler: true,
  },
};

Adopt incrementally on a large codebase:

const config: NextConfig = {
  experimental: {
    reactCompiler: {
      compilationMode: 'annotation',   // only components with "use memo"
    },
  },
};
function ProductGrid({ products }: Props) {
  'use memo';     // opt this component in
  // …
}

Or opt in by directory:

reactCompiler: {
  sources: (filename) => filename.includes('/components/plp/'),
}

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:

  1. React Profiler, record the interaction, note total commit duration.
  2. Add the memoization.
  3. Record again. Note the new duration.
  4. 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

  1. 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.
  2. Fix the sources: useCallback for handlers, useMemo for object/array props, or restructure to pass primitives.
  3. Memoize the highest‑count component on your list page. Measure the commit duration before and after. Keep it only if it dropped.
  4. Audit context values: rg -n "\.Provider value=\{\{" app components — every hit is an unmemoized object literal.
  5. Run the compiler healthcheck. Record the bail‑out rate and the top three causes.
  6. Enable the compiler in annotation mode on one component tree and measure INP.
  7. 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 useMemo on 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