React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

useMemouseCallbackmemomemoizationperformancereferential-equalityreact-compiler

useMemo, useCallback & React.memo — Cache values, stabilize references, and skip re-renders

Recipe

import { memo, useMemo, useCallback, useState } from "react";
 
interface Product {
  id: string;
  name: string;
  price: number;
  category: string;
}
 
// 1. useMemo — cache an expensive computation
function ProductFilter({ products, category }: { products: Product[]; category: string }) {
  const filtered = useMemo(
    () => products.filter((p) => p.category === category).sort((a, b) => a.price - b.price),
    [products, category]
  );
 
  return <ProductList items={filtered} />;
}
 
// 2. useCallback — stabilize function reference for memoized children
function ProductPage() {
  const [selected, setSelected] = useState<string | null>(null);
 
  const handleSelect = useCallback((id: string) => {
    setSelected(id);
  }, []);
 
  return <MemoizedProductGrid onSelect={handleSelect} />;
}
 
// 3. React.memo — skip re-render when props are unchanged
const MemoizedProductGrid = memo(function ProductGrid({
  onSelect,
}: {
  onSelect: (id: string) => void;
}) {
  return <div>{/* expensive grid rendering */}</div>;
});

When to reach for this: After profiling reveals that a specific computation or component re-render is causing measurable slowness. Never as a default — the React Compiler handles most memoization automatically in React 19+.

Working Example

// ---- BEFORE: No memoization — filtering 10,000 products on every keystroke ----
 
function StorePage() {
  const [products] = useState<Product[]>(generateProducts(10_000));
  const [search, setSearch] = useState("");
  const [sortBy, setSortBy] = useState<"price" | "name">("name");
  const [cartCount, setCartCount] = useState(0);
 
  // Recomputed on EVERY render, including cart updates (costs ~45ms)
  const results = products
    .filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
    .sort((a, b) =>
      sortBy === "price" ? a.price - b.price : a.name.localeCompare(b.name)
    );
 
  return (
    <div>
      <header>Cart: {cartCount}</header>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
        Sort by {sortBy === "price" ? "name" : "price"}
      </button>
      <button onClick={() => setCartCount((c) => c + 1)}>Add to cart</button>
      <ul>
        {results.map((p) => (
          <ProductRow
            key={p.id}
            product={p}
            onAddToCart={() => setCartCount((c) => c + 1)} // New fn every render
          />
        ))}
      </ul>
    </div>
  );
}
 
function ProductRow({
  product,
  onAddToCart,
}: {
  product: Product;
  onAddToCart: () => void;
}) {
  return (
    <li>
      {product.name} — ${product.price}
      <button onClick={onAddToCart}>Add</button>
    </li>
  );
}
 
// ---- AFTER: Memoized — filtering only runs when search or sortBy changes ----
 
function StorePageOptimized() {
  const [products] = useState<Product[]>(generateProducts(10_000));
  const [search, setSearch] = useState("");
  const [sortBy, setSortBy] = useState<"price" | "name">("name");
  const [cartCount, setCartCount] = useState(0);
 
  // Only recomputes when search, sortBy, or products change (~45ms saved on cart clicks)
  const results = useMemo(
    () =>
      products
        .filter((p) => p.name.toLowerCase().includes(search.toLowerCase()))
        .sort((a, b) =>
          sortBy === "price" ? a.price - b.price : a.name.localeCompare(b.name)
        ),
    [products, search, sortBy]
  );
 
  const handleAddToCart = useCallback(() => {
    setCartCount((c) => c + 1);
  }, []);
 
  return (
    <div>
      <header>Cart: {cartCount}</header>
      <input value={search} onChange={(e) => setSearch(e.target.value)} />
      <button onClick={() => setSortBy(sortBy === "price" ? "name" : "price")}>
        Sort by {sortBy === "price" ? "name" : "price"}
      </button>
      <button onClick={handleAddToCart}>Add to cart</button>
      <ul>
        {results.map((p) => (
          <MemoizedProductRow
            key={p.id}
            product={p}
            onAddToCart={handleAddToCart}
          />
        ))}
      </ul>
    </div>
  );
}
 
const MemoizedProductRow = memo(function ProductRow({
  product,
  onAddToCart,
}: {
  product: Product;
  onAddToCart: () => void;
}) {
  return (
    <li>
      {product.name} — ${product.price}
      <button onClick={onAddToCart}>Add</button>
    </li>
  );
});

What this demonstrates:

  • useMemo caches the filter+sort result: clicking "Add to cart" no longer recomputes 10,000 items (saves ~45ms per click)
  • useCallback stabilizes handleAddToCart so MemoizedProductRow can skip re-renders
  • memo on ProductRow prevents 10,000 list items from re-rendering when only the cart count changes
  • Combined effect: interaction latency drops from ~200ms to under 5ms on cart updates

Deep Dive

How It Works

  • useMemo(fn, deps) calls fn on mount and caches the result. On subsequent renders, it compares each dependency (using Object.is) against the previous values. If all match, it returns the cached value without calling fn. If any changed, it calls fn again and caches the new result.
  • useCallback(fn, deps) is syntactic sugar for useMemo(() => fn, deps). It caches the function reference itself rather than the function's return value. This keeps the reference stable across renders as long as dependencies do not change.
  • React.memo(Component, areEqual?) wraps a component so React performs a shallow prop comparison before rendering. If all props are shallowly equal to the previous render, React skips the component and reuses the last rendered output. An optional areEqual function can customize the comparison.
  • Shallow comparison checks reference equality for objects and arrays. Two objects { a: 1 } and { a: 1 } are not shallowly equal because they are different references, even though their contents match.
  • React never guarantees cache retentionuseMemo and useCallback are performance hints, not semantic guarantees. React may discard cached values under memory pressure or during future concurrent features.

Variations

useMemo for referential equality (not just expensive computations):

// Without useMemo: new array reference triggers useEffect in child
function Parent() {
  const config = { theme: "dark", locale: "en" }; // New object every render
 
  return <Dashboard config={config} />;
}
 
// With useMemo: stable reference prevents unnecessary effect runs
function Parent() {
  const config = useMemo(() => ({ theme: "dark", locale: "en" }), []);
 
  return <Dashboard config={config} />;
}

Custom comparison function with memo:

const ChartWidget = memo(
  function ChartWidget({ data, title }: { data: number[]; title: string }) {
    return <canvas>{/* expensive chart render */}</canvas>;
  },
  (prev, next) => {
    // Skip re-render if array length and title are the same
    return prev.data.length === next.data.length && prev.title === next.title;
  }
);

When NOT to memoize:

// BAD: memoizing a trivial computation
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
 
// GOOD: just compute it — string concatenation is near-instant
const greeting = `Hello, ${name}!`;
 
// BAD: memoizing a primitive that never changes reference
const TAX_RATE = useMemo(() => 0.08, []);
 
// GOOD: just use a constant
const TAX_RATE = 0.08;

TypeScript Notes

  • useMemo<T> infers T from the factory function return type. Explicit annotation is rarely needed.
  • useCallback infers the function signature. TypeScript enforces that the callback type matches what children expect.
  • For generic memoized components, use a type assertion: memo(GenericList) as typeof GenericList.
  • The areEqual comparator in memo receives (prevProps: Readonly<Props>, nextProps: Readonly<Props>).

Gotchas

  • Memoizing without stabilizing dependenciesuseMemo recalculates if any dependency is a new reference. Passing an inline object as a dependency defeats the cache. Fix: Ensure all dependencies are primitives or stable references.

  • useCallback with missing dependencies — Omitting a dependency from useCallback creates a stale closure that captures old state values. Fix: Always include all referenced values. Enable the react-hooks/exhaustive-deps ESLint rule.

  • memo on components receiving childrenchildren is a new React element tree on every render, so memo is ineffective for components that receive children. Fix: Either memoize the children at the parent level or restructure to avoid passing children to memoized components.

  • Over-memoizing — Wrapping every component in memo and every value in useMemo adds comparison overhead that slows down simple components. Fix: Profile first. Only memoize components that the Profiler shows are expensive (over 5ms render time) and re-rendering unnecessarily.

  • React Compiler makes most manual memoization obsolete — In React 19+ with the compiler enabled, useMemo, useCallback, and memo are applied automatically at build time. Fix: If using React Compiler, remove manual memoization and only add it back if profiling shows the compiler missed a case.

  • useMemo does not guarantee the cache persists — React documents that useMemo is a hint, not a guarantee. Future React versions may discard caches more aggressively. Fix: Never rely on useMemo for correctness, only for performance. Your code must work correctly even if every useMemo recomputes on every render.

Alternatives

ApproachTrade-off
React CompilerAutomatic memoization; no manual hooks needed; requires React 19+
State colocationMove state closer to usage; avoids the need to memoize in the first place
useDeferredValueDefers updates instead of caching; shows stale data briefly
VirtualizationRenders only visible items; more effective than memo for large lists
Web WorkersOffloads heavy computation off the main thread; complex data transfer
Zustand selectorsFine-grained state subscriptions without memo or context

Real-World Example

From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).

React.memo with Custom Comparison

// Production example: SavePointCheckbox with custom memo comparison
// File: src/components/study/save-point-checkbox.tsx
'use client';
import React, { memo } from 'react';
 
interface SavePointCheckboxProps {
  pointId: string;
  isSaved: boolean;
  onSavedChange: (pointId: string, isSaved: boolean) => void;
}
 
const SavePointCheckbox = memo(
  function SavePointCheckbox({ pointId, isSaved, onSavedChange }: SavePointCheckboxProps) {
    return (
      <input
        type="checkbox"
        checked={isSaved}
        onChange={(e) => onSavedChange(pointId, e.target.checked)}
        aria-label="Save this point"
      />
    );
  },
  (prev, next) => {
    // Custom comparison: ignore callback identity, compare only data props
    return prev.pointId === next.pointId && prev.isSaved === next.isSaved;
  }
);

useMemo and useCallback in a Custom Hook

// Production example: Quiz hook with memoized return value
// File: src/hooks/use-quiz.ts
import { useState, useMemo, useCallback } from 'react';
 
export function useQuiz(questions: Question[]) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [answers, setAnswers] = useState<Map<string, string>>(new Map());
 
  const currentQuestion = useMemo(
    () => questions[currentIndex],
    [questions, currentIndex]
  );
 
  const submitAnswer = useCallback(
    (questionId: string, answer: string) => {
      setAnswers((prev) => new Map(prev).set(questionId, answer));
    },
    []
  );
 
  const goToNext = useCallback(() => {
    setCurrentIndex((prev) => Math.min(prev + 1, questions.length - 1));
  }, [questions.length]);
 
  return useMemo(() => ({
    currentQuestion,
    submitAnswer,
    goToNext,
    progress: currentIndex / questions.length,
  }), [currentQuestion, submitAnswer, goToNext, currentIndex, questions.length]);
}

What this demonstrates in production:

  • The custom comparison in SavePointCheckbox was necessary because the parent recreates the onSavedChange callback on every render. Without the custom comparator, every checkbox in a list of 50+ items would re-render whenever any single checkbox changed.
  • The custom comparator intentionally skips comparing onSavedChange. This is safe because the callback always has the same behavior (it calls the same store action) even though its reference changes.
  • In useQuiz, the useMemo on the return object is critical. Without it, every component consuming this hook would receive a new object reference on every render, defeating any memo wrappers downstream.
  • submitAnswer uses useCallback with an empty dependency array because it only calls setAnswers with a functional updater. The updater function always receives the latest state, so no external dependencies are needed.
  • new Map(prev).set(questionId, answer) creates a new Map reference so React detects the state change. Mutating the existing Map with prev.set(...) would not trigger a re-render.

FAQs

What is the difference between useMemo and useCallback?
  • useMemo(fn, deps) caches the return value of fn.
  • useCallback(fn, deps) caches the function reference itself.
  • useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
When should you NOT use useMemo?
  • For trivial computations like string concatenation or arithmetic.
  • For constants that never change (just use a const).
  • When the React Compiler (React 19+) is enabled -- it handles memoization automatically.
Why does React.memo not work when a component receives children?
  • children is a new React element tree on every render, creating a new reference each time.
  • The shallow comparison in memo sees a different reference and re-renders anyway.
  • Fix: Memoize the children at the parent level or restructure to avoid passing children.
How does the custom comparison function in React.memo work?
const MyComponent = memo(
  function MyComponent({ data, title }: Props) {
    return <div>{title}: {data.length} items</div>;
  },
  (prev, next) => {
    // Return true to SKIP re-render, false to re-render
    return prev.data.length === next.data.length
      && prev.title === next.title;
  }
);
Gotcha: What happens if you pass an inline object as a useMemo dependency?
  • Inline objects create a new reference on every render.
  • useMemo sees a different dependency each time and recalculates, defeating the cache.
  • Fix: Ensure all dependencies are primitives or stable references (via useMemo/useCallback).
Why is it important that useMemo is a hint, not a guarantee?
  • React may discard cached values under memory pressure or during concurrent features.
  • Your code must work correctly even if every useMemo recomputes on every render.
  • Never use useMemo for correctness -- only for performance optimization.
How does the React Compiler affect manual memoization?
  • In React 19+ with the compiler enabled, useMemo, useCallback, and memo are applied automatically at build time.
  • Manual memoization becomes redundant and adds unnecessary code.
  • Fix: Remove manual memoization when using the compiler; add it back only if profiling shows a gap.
Gotcha: What is a stale closure bug with useCallback?
  • Omitting a dependency from useCallback captures old state values in the closure.
  • The callback reads stale data instead of the current state.
  • Fix: Include all referenced values in the dependency array; enable react-hooks/exhaustive-deps ESLint rule.
How do you type a generic memoized component in TypeScript?
// React.memo loses generics, so use a type assertion
function GenericList<T>({ items }: { items: T[] }) {
  return <ul>{items.map((item, i) => <li key={i}>{String(item)}</li>)}</ul>;
}
 
const MemoizedList = memo(GenericList) as typeof GenericList;
// Usage: <MemoizedList<number> items={[1, 2, 3]} />
What is the type signature of the areEqual comparator in React.memo?
  • (prevProps: Readonly<Props>, nextProps: Readonly<Props>) => boolean
  • Return true to skip re-render, false to allow it.
  • Both arguments are Readonly -- you cannot mutate props inside the comparator.
Why should you return a memoized object from a custom hook?
  • Without useMemo on the return value, every consumer receives a new object reference each render.
  • This defeats any memo wrappers downstream.
return useMemo(() => ({
  currentQuestion,
  submitAnswer,
  progress,
}), [currentQuestion, submitAnswer, progress]);
How do you decide between useMemo, state colocation, and virtualization for a slow list?
  • useMemo: Caches expensive filter/sort; good when the list is under ~500 items.
  • State colocation: Move state closer to the component that uses it to avoid re-rendering siblings.
  • Virtualization: Renders only visible items; most effective for lists over 100 items.