Skip to main content

useMemo and useCallback: Referential Stability

What you'll learn

  • What useMemo and useCallback really cache: a value's identity, keyed by its deps
  • Why useCallback(fn, deps) is literally useMemo(() => fn, deps)
  • The three places where identity actually matters: memoized children, effect deps, other memos
  • When NOT to use these hooks (most places), and what they cost
  • A worked example: what actually breaks with and without them

Ask ten developers what useMemo is for and nine will say "performance". Then they wrap every function in useCallback and every object in useMemo, and the app does not get faster. These hooks do something far more specific than "speed": they keep a value's identity stable between renders. Once you see where identity matters, every correct use becomes obvious, and so does every place they are pure noise.

The mechanism: a value slot with a dependency key

Jargon: "referential equality". Comparing two values with ===, asking "are these the same object?" For objects and functions, === compares identity, not content: {a: 1} === {a: 1} is false.

Here is the problem. Every render re-runs your component function, so every object, array, or function literal gets a brand-new identity. Usually that is harmless. Sometimes it causes real trouble. useMemo lets you keep the old identity until its inputs actually change:

import { useMemo, useState } from 'react';

function slowFilter(items, query) {
  console.log('filter ran');
  return items.filter((i) => i.toLowerCase().includes(query.toLowerCase()));
}

const FRUITS = ['Apple', 'Banana', 'Cherry', 'Date'];

export default function Search() {
  const [query, setQuery] = useState('');
  const [dark, setDark] = useState(false);

  const results = useMemo(() => slowFilter(FRUITS, query), [query]);

  return (
    <div style={{ background: dark ? '#222' : '#fff' }}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Filter fruit"
      />
      <button onClick={() => setDark(!dark)}>Toggle theme</button>
      <ul>{results.map((r) => <li key={r}>{r}</li>)}</ul>
    </div>
  );
}

What happens:

  1. First render: there is no cached value, so slowFilter runs ("filter ran" logs) and React stores the result plus the deps [query] on the hook cell.
  2. Click "Toggle theme": the component re-renders, useMemo compares the new deps to the stored deps with Object.is, same query string, so it skips slowFilter and returns the stored array. No log.
  3. Type a letter: query changed, the comparison fails, slowFilter runs again and the new result is stored.
  4. Notice what you really bought: not just skipped work, results keeps the same identity across theme toggles.

Pseudocode model, not real source:

// Conceptually, per hook cell:
function useMemo(compute, deps) {
const cell = getCurrentHookCell();
const same = cell.deps
&& deps.length === cell.deps.length
&& deps.every((d, i) => Object.is(d, cell.deps[i]));
if (!same) {
cell.value = compute(); // recompute...
cell.deps = deps; // ...and remember the new key
}
return cell.value; // same identity until deps change
}

Jargon: "hook cell". The per-component, per-hook storage slot React keeps between renders. It is how any hook remembers anything: state lives in cells, and so do memo caches.

Jargon: "deps" (dependency array). The list of values React compares with Object.is on each render to decide whether the cached value is still valid. Any changed dep invalidates the cache.

useCallback is not a separate invention

const onSelect = useCallback((id) => setSelected(id), []);
// means exactly:
const onSelect = useMemo(() => (id) => setSelected(id), []);

That is all it is: "cache this function's identity." The function is still created on every render (you wrote it inline); useCallback simply discards the new one and returns the old one while deps match. It exists as a separate hook only because "cache this function" is such a common need.

Where identity matters #1: props of a memoized child

Jargon: "React.memo". A wrapper telling React: "if this component's props are shallowly equal (Object.is per prop) to last render's, skip re-rendering it."

Memo is a boundary, and unstable props punch holes in it:

import { memo, useCallback, useState } from 'react';

const ITEMS = [
  { id: 1, label: 'Inbox' },
  { id: 2, label: 'Starred' },
  { id: 3, label: 'Trash' },
];

const Row = memo(function Row({ item, onSelect }) {
  console.log('Row rendered:', item.label);
  return <li onClick={() => onSelect(item.id)}>{item.label}</li>;
});

export default function Sidebar() {
  const [selected, setSelected] = useState(null);
  const [count, setCount] = useState(0);

  const handleSelect = useCallback((id) => setSelected(id), []);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>
        Unrelated counter: {count}
      </button>
      <p>Selected: {selected ?? 'none'}</p>
      <ul>
        {ITEMS.map((item) => (
          <Row key={item.id} item={item} onSelect={handleSelect} />
        ))}
      </ul>
    </div>
  );
}

(Note that ITEMS is module-level, so each item keeps a stable identity too.)

What happens (with useCallback):

  1. Click the counter: Sidebar re-renders because its state changed.
  2. useCallback's deps [] are unchanged → the same handleSelect function identity is returned.
  3. React renders each <Row> element; memo compares props: item is the same object, onSelect is the same function → bail out. No row re-renders. The console stays quiet.

Now watch the chain break. Remove useCallback:

const handleSelect = (id) => setSelected(id); // a NEW function every render
  1. Click the counter → Sidebar re-renders → a brand-new handleSelect is created.
  2. memo compares props for each Row: onSelect !== previous onSelect, Object.is fails.
  3. Every row re-renders: "Row rendered: Inbox", "Row rendered: Starred"… on every counter click.
  4. One unstable prop defeats memo entirely. The chain: parent renders → new identity → memo's comparison fails → child re-renders.

This is the real reason useCallback exists: to protect the memo boundary below you.

Where identity matters #2: effect dependency arrays

Effect deps use the same Object.is comparison, which produces the infamous "effect runs in a loop" bug:

import { useEffect, useState } from 'react';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  const options = { url: `/api/users/${userId}`, method: 'GET' };

  useEffect(() => {
    fetch(options.url).then((r) => r.json()).then(setUser);
  }, [options]); // ⚠️ options is a NEW object every render!

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}

What happens:

  1. Render: options = { … }, a fresh object identity.
  2. The effect compares deps: Object.is(newOptions, oldOptions) is false → the effect re-runs.
  3. The fetch resolves → setUser → re-render → another new options object → the effect re-runs → fetch again → …
  4. The network tab shows an endless stream of requests. Nobody wrote a loop; identity wrote it for you.

Two honest fixes. Fix A, build what you need inside the effect so it stops being a dependency:

import { useEffect, useState } from 'react';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then(setUser);
  }, [userId]); // a primitive dep: stable by value

  return <pre>{JSON.stringify(user, null, 2)}</pre>;
}

Fix B, stabilize the object itself: const options = useMemo(() => ({ url: ... }), [userId]);. Fix A is usually better; Fix B is for when the object is genuinely shared between the effect and the JSX.

Where identity matters #3: deps of other memos

Memoized values compose:

const filtered = useMemo(() => items.filter(pred), [items, pred]);
const sorted = useMemo(() => [...filtered].sort(compare), [filtered]);

sorted's cache key includes filtered's identity. If filtered were not memoized, it would be a new array every render and sorted could never hit its cache. Stable identity in, stable cache out.

When NOT to reach for them

  • Cheap calculations. useMemo(() => a + b, [a, b]), the comparison ceremony costs more than the addition.
  • Primitives. Strings, numbers, and booleans are compared by value anyway; a memoized 42 is the same 42. Identity is a non-issue.
  • Props of non-memoized children. A plain child re-renders whenever its parent renders, regardless of prop identity, useCallback on its props buys nothing until someone wraps the child in memo.
  • By default. Start with nothing memoized; add it where a measured problem or a memo boundary actually exists.

And the cost is real: memory (cells hold the value and the deps), a comparison on every render, and code noise that buries intent. One more reason to write fewer of them by hand: the React Compiler, the performance part of the project, automatically memoizes values and functions during the build. As it matures, hand-written useMemo/useCallback for stability become mostly unnecessary, something you write only to express intent.

Worked example: with and without

A filterable list with a memoized row, a memoized filter, and a stable callback:

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

const PRODUCTS = [
  { id: 1, name: 'Keyboard', price: 80 },
  { id: 2, name: 'Mouse', price: 40 },
  { id: 3, name: 'Monitor', price: 300 },
  { id: 4, name: 'Webcam', price: 90 },
];

const Row = memo(function Row({ product, onSelect }) {
  console.log('Row rendered:', product.name);
  return (
    <li>
      {product.name} (${product.price})
      <button onClick={() => onSelect(product.id)}>select</button>
    </li>
  );
});

export default function ProductList() {
  const [query, setQuery] = useState('');
  const [selected, setSelected] = useState(null);

  const visible = useMemo(
    () => PRODUCTS.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())),
    [query]
  );

  const onSelect = useCallback((id) => setSelected(id), []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter" />
      <p>Selected: {selected ?? 'none'}</p>
      <ul>
        {visible.map((p) => <Row key={p.id} product={p} onSelect={onSelect} />)}
      </ul>
    </div>
  );
}

What happens:

  1. Typing changes queryvisible recomputes → the matching rows render. Selecting a product re-renders the list (the selected state changed), but onSelect's identity is stable and the product objects are module-level constants, so no Row re-renders.
  2. The chain holds: stable onSelect + stable product objects + memo(Row) = rows render only when their own data changes.

Now remove all three optimizations, a plain Row function, an inline PRODUCTS.filter(...) in the JSX, an inline onSelect arrow, and replay:

  1. Typing still filters correctly and feels instant. Four items were never the bottleneck; removing useMemo changed nothing you can perceive.
  2. Selecting a product now re-renders every row: the parent rendered, Row is not memoized, so all rows re-render. (Memo alone would not even help, the inline onSelect is a new function each time.)
  3. What actually broke: extra Row renders. Not the filter, not correctness. With 4 rows it is invisible; with 4,000 heavy rows it is a janky keystroke.

That is the honest summary: these hooks change how often things render, never what renders. If removing them changes what you see on screen, something else was wrong.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: useMemo makes code faster. Reality: it makes identity stable; it only saves time when the computation itself is expensive, and it costs a comparison on every render.
  • Misconception: useCallback stops the function from being created. Reality: the inline function is created every render regardless; the hook just keeps returning the old one while deps match.
  • Misconception: memoized values are guaranteed never to recompute. Reality: it is a cache, not a contract, React may discard it (for example, to free memory for offscreen content) and recompute when needed. Never build correctness on it.
  • Misconception: wrapping everything is harmless. Reality: each wrapper costs memory, comparison time, and readability, and unstable-by-default is fine wherever nothing depends on identity.
  • Misconception: useCallback(fn, []) always sees the latest state. Reality: with empty deps it closes over the first render's values; use updater functions (setX(x => ...)) or list the dep.

Why it works this way

  • Identity is a JavaScript fact: === on objects and functions compares references. React builds memo and deps on that fact instead of deep comparisons, which would be slow and unpredictable.
  • Hook cells make "remember the last value plus its key" trivial: each hook call gets a slot, compared and refilled in call order.
  • Stability is opt-in because most identity churn is harmless; stabilizing everything by default would cost memory and mask real data changes.
  • The compiler direction exists because humans are bad at placing these by hand, the rule ("stabilize what crosses a memo boundary") is mechanical enough to automate.

Try it yourself

  1. In ProductList, add console.count('Row ' + product.name) inside Row. Select products with and without useCallback on onSelect and compare the counts.
  2. Generate 5,000 products in code, remove the useMemo around visible, throttle your CPU, and type. Then restore it. Feel where the cost actually lives.
  3. Reproduce the options-loop bug in UserProfile, watch the network tab fill up, then fix it by moving the object inside the effect.
  4. Rewrite useCallback((id) => setSelected(id), []) as useMemo(() => (id) => setSelected(id), []) and confirm the behavior is identical.

Recap

  • useMemo caches a value's identity on a hook cell, keyed by deps compared with Object.is.
  • useCallback(fn, deps) is exactly useMemo(() => fn, deps).
  • The point is referential stability, and it matters in three places: memoized children, effect dep arrays, and other memos' deps.
  • A new inline object or function each render is the root of both the "memo defeated" and the "effect loop" bugs.
  • Do not memo cheap work, primitives, or props of non-memoized children, you pay memory, comparison, and noise for nothing.
  • These hooks change how often things render, never what renders, and the React Compiler may soon write them for you.

Next

Context deep dive →