Chapter 8

useMemo and useCallback: When to Optimize, When to Over-Engineer

Memoization: Trading Memory for CPU Cycles

Both useMemo and useCallback implement memoization: caching the result of a computation and returning the cached value on subsequent calls as long as the inputs have not changed, skipping the computation entirely.

// Without useMemo: filter runs on every render
const filteredList = items.filter(item => item.active);

// With useMemo: filter only runs when items changes
const filteredList = useMemo(
  () => items.filter(item => item.active),
  [items]
);
// Without useCallback: new function reference on every render
const handleClick = () => doSomething(id);

// With useCallback: same function reference as long as id is unchanged
const handleClick = useCallback(() => doSomething(id), [id]);

The critical question developers skip: memoization is not free. It consumes memory to cache results, and it consumes CPU on every render to compare the dependency values against the previous ones. If the cache hit rate is low — meaning dependencies change frequently — memoization adds overhead rather than removing it.

Understanding when memoization helps versus when it hurts requires understanding both what it does and what the alternatives actually cost.

Referential Equality in JavaScript

To understand why useMemo and useCallback matter at all, you need to understand how JavaScript determines whether two values are equal.

// Primitives: equality by value
5 === 5                // true
'hello' === 'hello'    // true

// Objects: equality by reference
{} === {}              // false (two different objects)
[] === []              // false (two different arrays)
(() => {}) === (() => {}) // false (two different functions)

Every time React renders a component, object literals, array literals, and function expressions inside the component body create brand new references. Even if the contents are byte-for-byte identical, the new value is not equal to the previous one in JavaScript's eyes.

This creates two categories of problems:

Category 1: React.memo children. React.memo skips re-rendering when props are shallowly equal. If a prop is a freshly created function or object on every render, shallow comparison always returns false — React.memo is defeated before it can help.

Category 2: useEffect dependency arrays. If a dependency is a freshly created object on every render, the effect will re-run on every render even when the data has not meaningfully changed.

Memoization solves these problems by guaranteeing referential stability when dependencies have not changed.

Where useMemo Actually Helps

Case 1: Providing Stable References to Memoized Children

function Parent({ userId }: { userId: string }) {
  // Without useMemo: new object on every render, Child's memo is useless
  const config = { theme: 'dark', locale: 'en-US', userId };

  // With useMemo: config reference is stable when userId doesn't change
  const config = useMemo(
    () => ({ theme: 'dark', locale: 'en-US', userId }),
    [userId]
  );

  return <ExpensiveChild config={config} />;
}

const ExpensiveChild = React.memo(function ExpensiveChild({ config }) {
  // Only re-renders when config reference changes (i.e., when userId changes)
  return <div>{/* expensive rendering logic */}</div>;
});

Critical nuance: useMemo only provides value here because ExpensiveChild is wrapped in React.memo. Without that wrapper, ExpensiveChild re-renders whenever Parent re-renders regardless of whether config is the same reference. The memoization is entirely wasted.

This is the most common useMemo misuse: applying it to props passed to non-memoized children, under the mistaken belief that it prevents re-renders on its own.

Case 2: Genuinely Expensive Pure Computations

function DataAnalytics({ dataset, filters }: { dataset: Row[]; filters: FilterConfig }) {
  // If dataset has tens of thousands of rows, this may take hundreds of milliseconds
  const aggregated = useMemo(() => {
    return dataset
      .filter(row => matchesFilters(row, filters))
      .reduce((acc, row) => aggregateRow(acc, row), createEmptyAggregation());
  }, [dataset, filters]);

  return <Chart data={aggregated} />;
}

How do you know if a computation is "expensive enough" to memoize? Measure it:

console.time('expensive-compute');
const result = expensiveOperation(data);
console.timeEnd('expensive-compute');
// Under 1ms: memoization overhead is probably comparable — skip it
// 1-10ms: borderline, memoize if it runs frequently
// Over 10ms: almost certainly worth memoizing

The baseline comparison is not zero — it is the cost of comparing dependencies on every render. For simple dependency arrays of primitives, this is essentially free. For complex objects as dependencies, the comparison itself has a cost.

Where useCallback Actually Helps

useCallback(fn, deps) is precisely equivalent to useMemo(() => fn, deps) — it is syntactic sugar for memoizing functions specifically.

Case 1: Callbacks Passed to Memoized Children

function Parent() {
  const [todos, setTodos] = useState(initialTodos);

  // Without useCallback: new function on every render, TodoItem's memo is useless
  const handleToggle = (id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  };

  // With useCallback: stable reference, no spurious re-renders of TodoItem
  // Note: functional update means no dependency on todos
  const handleToggle = useCallback((id: string) => {
    setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t));
  }, []); // Empty deps because we use functional update form

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
      ))}
    </ul>
  );
}

const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <li>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      {todo.text}
    </li>
  );
});

The functional update form (setTodos(prev => ...)) is doubly valuable here: it makes the state update correct under concurrency and it eliminates todos from the dependency array, allowing a genuinely empty deps array.

Case 2: Functions as Effect Dependencies

function useSearch(query: string) {
  const [results, setResults] = useState<SearchResult[]>([]);

  // If fetchResults were defined inline without useCallback,
  // it would be a new function on every render, causing the effect
  // to re-run on every render regardless of query
  const fetchResults = useCallback(async (q: string, signal: AbortSignal) => {
    const data = await api.search(q, { signal });
    setResults(data);
  }, []); // api is a stable external reference

  useEffect(() => {
    const controller = new AbortController();
    fetchResults(query, controller.signal).catch(err => {
      if (err.name !== 'AbortError') console.error(err);
    });
    return () => controller.abort();
  }, [fetchResults, query]);

  return results;
}

Common Over-Optimization Mistakes

Mistake 1: useCallback for Non-Memoized Children

function Parent() {
  // Pointless: Child is not wrapped in React.memo
  // It re-renders whenever Parent re-renders, regardless of handleClick's stability
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

function Child({ onClick }: { onClick: () => void }) {
  // No React.memo — re-renders every time Parent re-renders
  return <button onClick={onClick}>Click</button>;
}

useCallback buys you nothing here. The Child component re-renders whenever Parent re-renders. The stable handleClick reference has no consumer that cares about it.

Mistake 2: Memoizing Trivial Computations

// Pointless: array.length is O(1) and cheaper than the memoization overhead
const count = useMemo(() => items.length, [items]);

// Pointless: simple string concatenation is microseconds
const fullName = useMemo(() => `${first} ${last}`, [first, last]);

// Pointless: straightforward boolean condition
const isAdmin = useMemo(() => user.role === 'admin', [user.role]);

// Just do this
const count = items.length;
const fullName = `${first} ${last}`;
const isAdmin = user.role === 'admin';

Memoization has a fixed overhead per render: allocating memory to store the previous result and dependencies, then executing the comparison logic. If the memoized computation is cheaper than this overhead, you have made the code slower while adding visual complexity.

Mistake 3: Defensive Memoization Everywhere

"It might help, so I'll add it everywhere" is one of the most counterproductive React patterns. Pervasive memoization:

  1. Makes code significantly harder to read and understand
  2. Creates maintenance burden — dependency arrays must be updated when logic changes, and failing to do so creates stale computation bugs
  3. Can actually decrease performance for small components where re-rendering is faster than the memoization overhead
  4. Masks the real performance issue — profiling becomes harder when everything is wrapped in memo

The correct workflow is measure first, optimize second — not the reverse.

React Compiler: Automatic Memoization at Compile Time

React 19 ships with the React Compiler (previously codenamed React Forget), a Babel/SWC transform that statically analyzes React components and automatically adds memoization where it is beneficial.

// You write this
function ProductList({ products, filter }) {
  const filtered = products.filter(p => p.category === filter);
  const handleSelect = (id) => selectProduct(id);

  return filtered.map(p => (
    <ProductCard key={p.id} product={p} onSelect={handleSelect} />
  ));
}

// React Compiler transforms it (conceptually) to something like:
function ProductList({ products, filter }) {
  const filtered = useMemo(
    () => products.filter(p => p.category === filter),
    [products, filter]
  );
  const handleSelect = useCallback((id) => selectProduct(id), []);

  return filtered.map(p => (
    <ProductCard key={p.id} product={p} onSelect={handleSelect} />
  ));
}

The Compiler analyzes the data flow and applies memoization precisely where it is beneficial, without the developer needing to reason about it manually.

Does React Compiler Make Manual Memoization Obsolete?

In principle, yes. In current practice, not entirely.

The React Compiler produces correct output only when your code follows React's rules: pure render functions, no direct state or prop mutation, hooks used correctly. When it encounters code that violates these rules, it conservatively skips optimization for that component rather than risking incorrect behavior.

As of late 2024, the Compiler is in Release Candidate status. Meta uses it across their production apps. The real-world results are significant — Meta reported measurable performance improvements without any manual optimization work. However, edge cases remain, particularly around:

Recommended approach today:

The Right Performance Methodology

Measure before you optimize. This maxim exists because developers are reliably bad at intuitively predicting where performance bottlenecks actually live.

// Step 1: Establish that there is a real user-visible performance problem
// (slowness that users experience, not just console logs)

// Step 2: Profile with React DevTools Profiler to find the actual bottleneck
// Look for: which components are rendering, how long they take, why they rendered

// Step 3: Understand why before fixing
// Is the component rendering too often? (fix: React.memo + stable refs)
// Is each render slow? (fix: useMemo for expensive computations)
// Is the issue elsewhere? (network, image loading, bundle size)

// Step 4: Apply targeted optimization and re-measure

Decision Framework

Do you have a measured performance problem?
├── No → Don't memoize. Write clear code.
└── Yes → Profile to find the bottleneck
    ├── Component renders too frequently
    │   └── Wrap in React.memo, stabilize props with useCallback/useMemo
    ├── Single render is slow (measured > 16ms)
    │   └── Profile the render, find expensive computation, apply useMemo
    ├── Both
    │   └── Fix render frequency first, then address render cost
    └── Neither (perf issue is elsewhere)
        └── Address the actual cause: bundle size, network, images, fonts

Performance Optimization Priority Order

The memoization hooks are not at the top of this list, and that is by design:

  1. Minimize state lifting — state held higher up causes wider re-render trees
  2. Separate stable and changing subtrees — co-locate state with the components that need it
  3. Code splitting and lazy loadingReact.lazy, dynamic import(), route-level splitting
  4. Virtualize long lists — react-virtual, react-window for lists with thousands of items
  5. Correct key usage — prevent unnecessary list reconciliation
  6. React.memo + stable refs — targeted application where profiling confirms benefit
  7. useMemo for expensive computations — last resort, only when single-render cost is measured to be high

A Complete, Correctly Memoized Example

Putting the principles together into a realistic component:

type Product = { id: string; name: string; category: string; rating: number; price: number };

function ProductCatalog({
  products,
  categoryFilter,
  onAddToCart,
}: {
  products: Product[];
  categoryFilter: string;
  onAddToCart: (id: string) => void;
}) {
  // Worth memoizing: potentially large dataset, non-trivial filter + sort
  const filteredAndSorted = useMemo(() => {
    return products
      .filter(p => p.category === categoryFilter)
      .sort((a, b) => b.rating - a.rating);
  }, [products, categoryFilter]);

  // Worth memoizing: passed to React.memo'd child, and onAddToCart may not be stable
  const handleAddToCart = useCallback((productId: string) => {
    onAddToCart(productId);
  }, [onAddToCart]);

  // Not worth memoizing: O(1) property access
  const totalCount = filteredAndSorted.length;

  // Not worth memoizing: simple string interpolation
  const headerText = `${totalCount} products in ${categoryFilter}`;

  return (
    <div>
      <h2>{headerText}</h2>
      {filteredAndSorted.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
}

// React.memo makes the useCallback above meaningful
const ProductCard = React.memo(function ProductCard({
  product,
  onAddToCart,
}: {
  product: Product;
  onAddToCart: (id: string) => void;
}) {
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

Every memoization decision here is justified by a concrete reason: the filteredAndSorted computation scales with dataset size, the handleAddToCart callback is passed to a memoized child. The trivial derivations are computed inline.

Summary

useMemo and useCallback are precision performance tools, not default coding habits. Their value materializes in exactly two scenarios: providing referentially stable values to React.memo-wrapped children or useEffect dependencies, and skipping genuinely expensive pure computations that would otherwise re-run on every render.

Overusing memoization is actively harmful — it adds code complexity, maintenance burden, and sometimes actual performance regression. The correct workflow is to profile first, identify real bottlenecks, and apply targeted optimization. Defensive "add it everywhere" memoization is a signal that the developer is optimizing by instinct rather than measurement.

React Compiler represents the trajectory of React optimization: shift the memoization burden to the compiler and let developers focus on application logic. As the Compiler matures and achieves wider adoption, manual useMemo and useCallback usage will become increasingly rare. Understanding the principles behind them, however, remains essential for anyone debugging performance, evaluating Compiler output, or working in codebases that predate the Compiler.

Rate this chapter
4.8  / 5  (44 ratings)

💬 Comments