Skip to content

useMemo and useCallback Correctly

intermediate13 min read

Memoization in React Has One Purpose: Referential Equality

Here's the thing most people get wrong about these hooks: they reach for useMemo and useCallback to "make things faster." That's usually the wrong reason. These hooks exist to preserve referential equality — ensuring that the same object/function reference is reused across renders so that downstream consumers (React.memo, useEffect dependencies, context values) do not trigger unnecessary work.

// Without useMemo — new array every render
const filtered = items.filter(i => i.active);
// Object.is(prevFiltered, nextFiltered) → false (always)

// With useMemo — same array when items and filter do not change
const filtered = useMemo(() => items.filter(i => i.active), [items]);
// Object.is(prevFiltered, nextFiltered) → true (when items is same)
Mental Model

Think of useMemo and useCallback as sticky labels on values. Without a label, React creates a new value every render and throws the old one away. With a label, React keeps the old value and checks: "did the ingredients (dependencies) change? No? Keep the labeled value. Yes? Make a new one and re-label it." The label is only useful if something downstream cares about identity — React.memo, useEffect deps, or context values. If nothing checks identity, the label is wasted effort.

useMemo: Caching Computed Values

const expensiveResult = useMemo(() => {
  return heavyComputation(data);
}, [data]);

useMemo calls the function and caches its return value. It only re-computes when a dependency changes.

When useMemo Actually Helps

1. Expensive computations with the same inputs:

function Dashboard({ transactions }) {
  // Sorting 10,000 transactions on every render is wasteful
  const sorted = useMemo(
    () => [...transactions].sort((a, b) => b.date - a.date),
    [transactions]
  );

  return <TransactionList transactions={sorted} />;
}

2. Preserving reference for React.memo children:

const MemoizedList = React.memo(function List({ items }) {
  return items.map(item => <li key={item.id}>{item.name}</li>);
});

function Parent() {
  const [query, setQuery] = useState('');
  const [items, setItems] = useState(allItems);

  // Without useMemo, filtered is a new array every render.
  // MemoizedList re-renders because items prop changed (new reference).
  const filtered = useMemo(
    () => items.filter(i => i.name.includes(query)),
    [items, query]
  );

  return <MemoizedList items={filtered} />;
}

3. Stabilizing context values:

function ThemeProvider({ children }) {
  const [mode, setMode] = useState('light');

  const value = useMemo(() => ({
    mode,
    toggle: () => setMode(m => m === 'light' ? 'dark' : 'light'),
  }), [mode]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

useCallback: Caching Function References

useCallback(fn, deps) is syntactic sugar for useMemo(() => fn, deps):

// These are equivalent:
const handleClick = useCallback(() => {
  setCount(c => c + 1);
}, []);

const handleClick = useMemo(() => {
  return () => setCount(c => c + 1);
}, []);

When useCallback Actually Helps

1. Passing callbacks to React.memo children:

const MemoizedButton = React.memo(function Button({ onClick, label }) {
  console.log('Button rendered:', label);
  return <button onClick={onClick}>{label}</button>;
});

function Toolbar() {
  const [count, setCount] = useState(0);

  // Without useCallback: new function every render → MemoizedButton re-renders
  const handleSave = useCallback(() => {
    save();
  }, []);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <MemoizedButton onClick={handleSave} label="Save" />
    </div>
  );
}

2. As useEffect dependencies:

function Chat({ roomId }) {
  const createConnection = useCallback(() => {
    return new WebSocket(`wss://chat.example.com/${roomId}`);
  }, [roomId]);

  useEffect(() => {
    const ws = createConnection();
    return () => ws.close();
  }, [createConnection]); // Stable when roomId is stable
}

When NOT to Use Them

Common Trap

Wrapping every function in useCallback and every value in useMemo is a common anti-pattern. Memoization is not free — React must store the previous value, compare dependencies on every render, and manage the cache. If nothing downstream checks referential equality, memoization adds cost without benefit.

// UNNECESSARY — no consumer checks referential equality
function Component() {
  // No React.memo child, no useEffect dep, no context value
  const handleClick = useCallback(() => {
    doSomething();
  }, []); // Wasted — no one benefits from this being stable

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

// UNNECESSARY — the computation is trivial
function Component({ items }) {
  const count = useMemo(() => items.length, []); // Overkill
  // items.length is O(1) — cheaper than the useMemo overhead itself
}

// UNNECESSARY — primitive values are already stable
function Component({ name }) {
  const greeting = useMemo(() => `Hello, ${name}`, [name]);
  // String concatenation is instant. useMemo adds overhead for nothing.
}

The Decision Framework

Ask these questions before adding useMemo or useCallback:

  1. Is the value passed to a React.memo component? → Maybe useMemo/useCallback
  2. Is the value in a useEffect dependency array? → Maybe useMemo/useCallback
  3. Is the value a context Provider value? → Probably useMemo
  4. Is the computation genuinely expensive (>1ms)? → Maybe useMemo
  5. None of the above? → Do not memoize

Production Scenario: Data Table with Memoized Derived State

const MemoizedRow = React.memo(function Row({ item, onDelete }) {
  return (
    <tr>
      <td>{item.name}</td>
      <td>{item.price}</td>
      <td><button onClick={() => onDelete(item.id)}>Delete</button></td>
    </tr>
  );
});

function DataTable({ items, sortField, filterQuery }) {
  const processedItems = useMemo(() => {
    let result = items;
    if (filterQuery) {
      result = result.filter(i =>
        i.name.toLowerCase().includes(filterQuery.toLowerCase())
      );
    }
    result = [...result].sort((a, b) =>
      a[sortField] > b[sortField] ? 1 : -1
    );
    return result;
  }, [items, sortField, filterQuery]);

  const handleDelete = useCallback((id) => {
    deleteItem(id);
  }, []);

  return (
    <table>
      <tbody>
        {processedItems.map(item => (
          <MemoizedRow
            key={item.id}
            item={item}
            onDelete={handleDelete}
          />
        ))}
      </tbody>
    </table>
  );
}
The React Compiler makes this automatic

The React Compiler (React Forget) auto-memoizes components and hooks at compile time. It analyzes data flow and inserts memoization where it detects referential equality would prevent unnecessary work. When the Compiler is widely adopted, manual useMemo and useCallback will largely become unnecessary. But understanding why they exist — referential equality — remains essential for debugging and for codebases without the Compiler.

Execution Trace
Render 1
useMemo(() => compute(data), [data])
Calls compute(data), stores result and deps [data]
Render 2
data unchanged
Object.is(prevData, nextData) → true → return cached result
Render 3
data changed
Object.is(prevData, nextData) → false → recompute, store new result
Cache
Only stores ONE previous result
Not a deep cache — just prev vs current deps
What developers doWhat they should do
Wrapping every function in useCallback 'for performance'
useCallback has overhead: storing previous function, comparing deps. Without a consumer that checks referential equality, it is pure cost.
Only use useCallback when the function is passed to React.memo children, used in useEffect deps, or is a context value
Using useMemo for trivial computations: useMemo(() => a + b, [a, b])
The useMemo overhead (dep comparison, cache management) exceeds the cost of simple arithmetic. Memoize only expensive computations.
Just compute directly: const sum = a + b
Adding useMemo/useCallback to fix a performance problem you have not measured
Premature memoization adds complexity. Profile to find what actually causes slow renders, then optimize specifically.
Profile first with React DevTools Profiler. Then memoize the measured bottleneck.
Expecting useMemo to be a deep cache (like lodash.memoize)
Alternating between two different dependency sets causes recomputation on every render. useMemo is not a multi-entry cache.
useMemo caches only ONE previous result — the most recent computation
Quiz
What is the relationship between useCallback and useMemo?
Quiz
When does useMemo provide zero benefit?
Quiz
useMemo caches how many previous results?
Key Rules
  1. 1useMemo and useCallback exist for referential equality — not for making things faster by default
  2. 2useCallback(fn, deps) === useMemo(() => fn, deps) — they are the same mechanism
  3. 3Only memoize when: passed to React.memo child, used as useEffect dep, or used in context value
  4. 4useMemo caches exactly ONE result — not an LRU cache or multi-entry cache
  5. 5The React Compiler will auto-memoize — understanding why matters more than when

Challenge: Optimize the Filtered List

Challenge: Strategic Memoization

// This component re-renders every child on every keystroke
// even though only the filtered list changes. Add memoization
// in the right places — and ONLY the right places.

function App() {
  const [query, setQuery] = useState('');
  const [items] = useState([
    { id: 1, name: 'React' },
    { id: 2, name: 'Vue' },
    { id: 3, name: 'Angular' },
    { id: 4, name: 'Svelte' },
  ]);

  const filtered = items.filter(i =>
    i.name.toLowerCase().includes(query.toLowerCase())
  );

  const handleDelete = (id) => {
    console.log('Delete', id);
  };

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <Stats count={filtered.length} />
      {filtered.map(item => (
        <ItemCard key={item.id} item={item} onDelete={handleDelete} />
      ))}
    </div>
  );
}

function Stats({ count }) {
  console.log('Stats rendered');
  return <p>`{count}` results</p>;
}

function ItemCard({ item, onDelete }) {
  console.log('ItemCard rendered:', item.name);
  return (
    <div>
      `{item.name}`
      <button onClick={() => onDelete(item.id)}>X</button>
    </div>
  );
}
Show Answer
const MemoizedStats = React.memo(Stats);
const MemoizedItemCard = React.memo(ItemCard);

function App() {
  const [query, setQuery] = useState('');
  const [items] = useState([
    { id: 1, name: 'React' },
    { id: 2, name: 'Vue' },
    { id: 3, name: 'Angular' },
    { id: 4, name: 'Svelte' },
  ]);

  const filtered = useMemo(
    () => items.filter(i =>
      i.name.toLowerCase().includes(query.toLowerCase())
    ),
    [items, query]
  );

  const handleDelete = useCallback((id) => {
    console.log('Delete', id);
  }, []);

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <MemoizedStats count={filtered.length} />
      {filtered.map(item => (
        <MemoizedItemCard key={item.id} item={item} onDelete={handleDelete} />
      ))}
    </div>
  );
}

Changes and reasons:

  1. React.memo(Stats) — prevents re-render when count has not changed
  2. React.memo(ItemCard) — prevents re-render when item and onDelete are stable
  3. useMemo on filtered — ensures the same array reference when query/items are the same (benefits ItemCard memo)
  4. useCallback on handleDelete — stable reference so MemoizedItemCard does not re-render due to changed onDelete prop

Without the React.memo wrappers, useMemo and useCallback would be pointless.