ML
React

useMemo, useCallback & React.memo: When Memoization Actually Pays

A senior engineer's guide to React's three memoization primitives, the referential-equality model underneath them, and why useCallback rarely makes your app faster.

May 20, 202611 min readReactHooks

React's memoization trio — useMemo, useCallback, and React.memo — is among the most cargo-culted parts of the API. They all hang off one idea: referential equality. Get that idea right and you know exactly when each one pays off and when it is just noise the next person has to read.

1. Referential equality: the root of everything

React decides whether to re-render and whether props "changed" using Object.is — a shallow, identity-based comparison. For primitives that matches value equality. For objects, arrays, and functions it compares the reference, not the contents. Two structurally identical literals are never equal.

Object.is(1, 1);                 // true
Object.is('a', 'a');             // true
Object.is({}, {});               // false — different references
Object.is([1], [1]);             // false
Object.is(() => {}, () => {});    // false

Every render of a function component re-executes its body. So every object, array, and arrow function written as a literal inside that body is a brand-new reference each render. That is the mechanism behind almost every "why does this re-render / re-run" question.

function Parent() {
  // new object, new array, new function every single render:
  const style = { color: 'red' };
  const items = data.filter(d => d.active);
  const onClick = () => doThing();
  return <Child style={style} items={items} onClick={onClick} />;
}

If Child is wrapped in React.memo, none of those props will ever look "equal" between renders, so the memo does nothing. This is the trap the three APIs exist to solve — and the trap they create when misused.

2. What each one actually does

They are not interchangeable. Each addresses a different unit of stability.

  • useMemo(fn, deps) caches a value. It runs fn and returns its result; on later renders it returns the same cached result (same reference) until a dependency changes by Object.is.
  • useCallback(fn, deps) caches a function identity. It is equivalent to useMemo(() => fn, deps) — sugar for memoizing a function value so its reference is stable across renders.
  • React.memo(Component) wraps a component and skips re-rendering it when a parent re-renders, provided its incoming props are shallow-equal to the previous props. It does not cache values or functions; it gates rendering. Note its scope: it only blocks renders caused by the parent. A memoized component still re-renders when its own useState updates or a context it reads with useContext changes.

The crucial connection: React.memo only helps if the props you pass are already referentially stable. useMemo and useCallback are how you make objects/arrays and functions stable enough for that gate to ever close. They are a team. One without the others is usually pointless.

const Child = React.memo(function Child({ items, onClick }) {
  return <ul onClick={onClick}>{items.map(renderRow)}</ul>;
});

function Parent({ data }) {
  const items = useMemo(() => data.filter(d => d.active), [data]);
  const onClick = useCallback(() => doThing(), []);
  // Now Child's props are stable; React.memo can actually skip re-renders.
  return <Child items={items} onClick={onClick} />;
}

3. When memoization actually pays

There are three legitimate categories. If your use case is not one of these, you are almost certainly adding noise.

(a) Genuinely expensive computation. useMemo earns its keep when the function body is costly relative to a render — sorting/filtering large datasets, parsing, building derived structures, heavy formatting. Here the goal is to avoid recomputing, and referential stability is a bonus.

const sorted = useMemo(
  () => rows.slice().sort(expensiveComparator),
  [rows]
);

(b) Stable props feeding a memoized child. When a heavy subtree is wrapped in React.memo and you pass it objects/arrays/callbacks, useMemo/useCallback keep those props stable so the memo gate can close. The payoff is skipping the child's render, not the memo itself.

(c) Values used as dependencies of other hooks. This is the case people forget, and it is about correctness, not just speed. If an object or function is a dependency of useEffect, useMemo, or a custom hook, an unstable reference causes the effect to fire on every render — extra fetches, resubscriptions, infinite loops.

// options is a new object each render => effect runs every render
const options = useMemo(() => ({ url, headers }), [url, headers]);

useEffect(() => {
  const sub = subscribe(options);
  return () => sub.unsubscribe();
}, [options]); // now only re-subscribes when url/headers truly change

Memoizing a function so it can safely live in a dependency array is one of the few times useCallback is unambiguously the right tool.

4. When it is noise — or actively harmful

Memoization is not free. useMemo/useCallback allocate, store deps, and run a comparison on every render. For cheap values that cost can exceed the work you "saved." The honest framing: you trade a little CPU and memory plus a lot of reading complexity for a chance to skip work later.

  • Memoizing primitives or trivial computation. useMemo(() => a + b, [a, b]) is slower and harder to read than a + b. There is no reference to stabilize.
  • Wrapping a callback that is not a dependency and is not passed to a memoized child. Stabilizing a function that nothing compares against accomplishes nothing.
  • React.memo on components whose props change every render anyway. The shallow compare runs, always fails, and you pay for the comparison plus get zero skipped renders. Children passed via props.children are a classic silent breaker — JSX children are new elements each render.
  • Harmful correctness bugs from stale closures. An over-aggressive useCallback(fn, []) can capture stale state/props, so the "stable" function reads outdated values. The reference is stable; the behavior is wrong.

Worth naming: the React Compiler reached its 1.0 stable release in October 2025. It is a separate build-time tool (not part of React core), compatible with React 17 and up, that inserts memoization automatically — which is precisely why hand-written memoization is increasingly something to justify rather than default to. If the compiler is enabled in your build, most manual useMemo/useCallback becomes redundant.

5. Dependency-array gotchas

The deps array is where memoization quietly goes wrong. The contract: list every reactive value the function reads. Lint with eslint-plugin-react-hooks and do not silence it casually.

  • Missing deps => stale values. The memoized value/function closes over old variables and never updates.
  • Unstable deps => useless memo. If a dep is itself a fresh object/array each render, the memo recomputes every time — you added cost for nothing. Stabilize the dep first, or depend on its primitive fields.
  • Object/array literals in deps. [{ id }] or a freshly built array defeat the cache unless that value is already memoized upstream.
  • Do not lie to the linter. Suppressing the exhaustive-deps warning to "fix" a loop hides the real bug; fix the reference instead. For event handlers that should not be reactive, prefer a ref or the useEffectEvent (Effect Event) pattern over an empty deps array.
// Bad: filters is a new object each render, so the memo never hits
const result = useMemo(() => query(data, filters), [data, filters]);

// Better: depend on the stable primitives the function actually reads
const result = useMemo(
  () => query(data, { status, sort }),
  [data, status, sort]
);

And remember React may discard a useMemo cache for its own reasons (for example, to free memory). Treat it as a performance hint, never a correctness guarantee — never put effects or required-once logic inside it.

6. The interviewer's favourite: "does useCallback make my app faster?"

The answer they want: usually no, and on its own, almost never. Here is the precise reasoning to give.

useCallback by itself does zero work-saving — it still creates the function (you wrote the arrow literal regardless), and now it also stores it and compares deps. A stable function reference only matters if something downstream uses the reference: a React.memo child whose render you skip, or a hook dependency you keep stable. If the consumer is not memoized, you have added overhead and bought nothing.

So the senior answer is conditional: useCallback speeds things up only when (1) the callback is passed to a memoized child or used as a dependency, and (2) keeping that reference stable lets React skip meaningful work. Absent those, it is a micro-pessimization dressed up as an optimization. The same logic applies to useMemo for non-expensive values.

Rules of thumb

  • Default to not memoizing. Write the plain code, measure with the Profiler, and memoize the hot path you can actually see.
  • Memoize for one of three reasons: expensive compute, stable props into a React.memo child, or a stable reference for a hook dependency. No reason, no memo.
  • React.memo only helps if every prop is already referentially stable — including children — and only blocks renders driven by the parent, not by the component's own state or context. One unstable prop defeats the whole gate.
  • Trust the exhaustive-deps lint. Stale closures from wrong deps are correctness bugs, not style nits.
  • useCallback alone does not make apps faster — and if the React Compiler (1.0 stable since October 2025) is enabled, most of this is handled for you. Manual memoization is something to justify, not a default.
SharePostLinkedIn

Reader Discussion

1 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Isabella Costa· Junior EngineerKind words

    saved this. sharing at standup tomorrow — we've had exactly this problem for 2 sprints and nobody on the team had framed it this way 🙏

    May 22, 2026·2 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email