useMemo & useCallback: When They Actually Help

Memoization is not free. Learn the two cases where it pays, and stop sprinkling it everywhere.

There is a myth that wrapping everything in useMemo and useCallback makes an app faster. It usually does not. Both hooks add memory, add a dependency array to keep correct, and run comparison logic on every render. They pay off in exactly two situations — outside those, they are noise.

The two cases that justify memoization

  1. A genuinely expensive calculation. Sorting or filtering thousands of rows, parsing, heavy formatting — work you do not want to repeat on unrelated re-renders. useMemo caches the result.
  2. A stable reference for a memoized consumer. If you pass an object, array, or function to a memo-wrapped child or into another hook's dependency array, a fresh reference each render defeats the whole point. useMemo/useCallback keep the reference steady.

Where it does not help

// Pointless: the sum is cheap and the value is not passed to a memo child.
const total = useMemo(() => a + b, [a, b]); // just write: const total = a + b;

Memoizing a trivial expression costs more than it saves. Reserve these hooks for real cost or real reference-stability needs.

The mental model

useCallback(fn, deps) is exactly useMemo(() => fn, deps) — one caches a function, the other caches any value. Neither is a magic speed switch; they are tools for controlling identity and recomputation. Measure with the React Profiler before reaching for them, and remember the React Compiler is increasingly able to insert this memoization for you.

Example

Example · javascript
import { useMemo, useCallback, useState, memo } from 'react';

const Row = memo(function Row({ item, onPick }) {
  // Re-renders only when item or onPick change by identity.
  return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});

function Directory({ people }) {
  const [query, setQuery] = useState('');
  const [picked, setPicked] = useState(null);

  // (1) Expensive-ish filtering: recompute only when inputs change.
  const results = useMemo(
    () => people.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
    [people, query]
  );

  // (2) Stable callback so memoized <Row> children don't re-render
  //     every time `query` changes.
  const handlePick = useCallback((id) => setPicked(id), []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>
        {results.map((p) => (
          <Row key={p.id} item={p} onPick={handlePick} />
        ))}
      </ul>
      <p>Picked: {picked ?? 'none'}</p>
    </div>
  );
}

When to use it

  • A memoized child component paired with useCallback on the parent's handler prevents the child from re-rendering during unrelated state changes in the parent — the one justified use case.
  • A developer profiles their app and discovers a filter + sort computation over 5000 items takes 40ms on every keystroke, justifying a useMemo wrapping the expensive chain.
  • A code review removes useMemo from a simple string concatenation that takes under 0.1ms, reducing complexity without any measurable performance difference.

More examples

useMemo justified on large sort

Justified useMemo: wraps a filter+sort over thousands of items that would measurably slow every keystroke.

Example · jsx
import { useState, useMemo } from 'react';

function SortedList({ items }) {
  const [query, setQuery] = useState('');

  // JUSTIFIED: items is large (thousands of entries)
  const filtered = useMemo(
    () => items
      .filter(i => i.name.includes(query))
      .sort((a, b) => a.name.localeCompare(b.name)),
    [items, query]
  );

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>{filtered.map(i => <li key={i.id}>{i.name}</li>)}</ul>
    </>
  );
}

useCallback paired with memo child

useCallback is justified here because the stable reference prevents the memo'd child from re-rendering on every count update.

Example · jsx
import { useState, useCallback, memo } from 'react';

const ExpensiveChild = memo(({ onAction }) => {
  console.log('ExpensiveChild rendered');
  return <button onClick={onAction}>Do action</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  // JUSTIFIED: onAction is passed to a memo'd child
  const onAction = useCallback(() => console.log('action'), []);

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <ExpensiveChild onAction={onAction} />
    </>
  );
}

When NOT to memoize

Contrasts unnecessary memoization of trivial values and unconstrained handlers with the cleaner, equally fast version.

Example · jsx
// UNNECESSARY — these are cheap and add overhead for no gain
function BadMemo({ title, count }) {
  // Simple string concat — do not wrap in useMemo
  const display = useMemo(() => `${title}: ${count}`, [title, count]);

  // Handler not passed to any memo'd child — do not wrap
  const handleClick = useCallback(() => setCount(c => c + 1), []);

  return <button onClick={handleClick}>{display}</button>;
}

// CORRECT — remove both, simpler and just as fast
function GoodMemo({ title, count }) {
  const display = `${title}: ${count}`;
  return <button onClick={() => setCount(c => c + 1)}>{display}</button>;
}

Discussion

  • Be the first to comment on this lesson.