React Memoization Visualized: useMemo, useCallback, and React.memo

Hoang Vu,4 min read

March 30, 2026

Memoization in React is not a magic switch. It is a trade-off between comparison cost, cache management cost, and re-render cost.

This article answers three practical questions:

  1. When does React.memo actually skip rendering?
  2. How does useCallback unlock React.memo effectiveness?
  3. What does useMemo cache, and when does it invalidate?

REACT MEMOIZATION DECISION MAP

Problem

problem

React.memo

memo

useCallback

callback

useMemo

memoValue

Result

result

Core idea

Parent re-renders propagate to children and expensive calculations.

Dependency contract

Caching only works when dependency arrays are accurate and prop references are stable.

Practical rule

Apply memoization only to proven hot paths, not everywhere by default.


1. Root problem: why do components re-render too much?

In React, when a parent re-renders, children usually re-render with the tree.

This is not always bad, but it becomes expensive when:

  1. children are heavy,
  2. props are unchanged,
  3. or expensive derivations run repeatedly.

Memoization is how we skip unnecessary work in those scenarios.


2. React.memo: skip child render when props are unchanged

React.memo(Component) performs a shallow comparison of previous and next props.

If props are shallow-equal, React can skip rendering that component.

const UserCard = React.memo(function UserCard({ user }) {
  return <div>{user.name}</div>;
});

Important nuance:

  1. React.memo does not mean "never render again".
  2. It only skips when that component's props are unchanged.
  3. If props are new object/function references each render, memoization often breaks.

2.1 What comparison does React actually use?

By default, React.memo does a shallow prop comparison with semantics close to Object.is per prop.

Quick model:

  1. Primitive props (number, string, boolean, null, undefined) compare by value.
  2. Object/array/function props compare by reference identity, not deep content.

So:

  1. count={1} across renders is often considered unchanged.
  2. {a: 1} recreated each render is a new reference, so considered changed.
  3. () => doSomething() recreated each render is also a new function reference.

Typical memo-breaking pattern:

<Child config={{ pageSize: 20 }} onSave={() => save(id)} />

Even if the logical values are "the same", child still re-renders because both config and onSave are new references.

2.2 How to stabilize object/function props

  1. Use useMemo for object/array props.
  2. Use useCallback for function props.
const config = useMemo(() => ({ pageSize: 20 }), []);
const onSave = useCallback(() => save(id), [id]);
 
return <Child config={config} onSave={onSave} />;

2.3 Custom comparator in React.memo (areEqual / isEqual)

React.memo accepts a custom comparator as the second argument:

const Child = React.memo(
  function Child(props) {
    return <div>{props.user.name}</div>;
  },
  (prevProps, nextProps) => {
    return prevProps.user.id === nextProps.user.id;
  },
);

Important rule (easy to invert):

  1. Comparator returns true => React skips rendering.
  2. Comparator returns false => React re-renders.

You may see names like areEqual, isEqual, or deep-compare helpers (e.g. lodash/isEqual), but use with caution:

  1. Deep compare can cost more CPU than the render you are trying to skip.
  2. Wrong comparator logic can produce stale UI.
  3. Ignoring function props in comparator can preserve stale closures.

Practical guidance:

  1. Stabilize references with useMemo/useCallback first.
  2. Use custom comparators only for profiled hotspots.
  3. Keep comparator logic small and obviously correct.

3. useCallback: stabilize function prop identity

React.memo frequently fails because function props are recreated on every parent render.

const onSave = () => save(id); // new function each render

Then the child receives a new prop reference, shallow compare fails, and child re-renders.

useCallback stabilizes function identity based on dependencies:

const onSave = useCallback(() => save(id), [id]);

USECALLBACK + REACT.MEMO IN PRACTICE

parent tick: 0

actions: 0

Inline callback (unstable reference)

memo child with inline callback

Child render count: 1

useCallback (stable reference)

memo child with stable callback

Child render count: 1

Parent re-renders change inline function identity, which breaks memo skip. useCallback keeps identity stable.

Quick checklist:

  1. Are you passing callbacks to memoized children?
  2. Does the parent re-render for unrelated state?
  3. Can callback identity be stabilized with useCallback?

4. useMemo: cache expensive derived values

useMemo memoizes a computed value.

const filtered = useMemo(() => {
  return products.filter((p) => p.name.includes(keyword));
}, [products, keyword]);

It helps when:

  1. computation is non-trivial,
  2. dependencies change less frequently,
  3. the derived value is reused across renders.

USEMEMO CACHE VS RE-RENDER NOISE

Without useMemo

Compute count: 1

React, Redux, Recoil, Relay, Remix

With useMemo

Compute count: 1

React, Redux, Recoil, Relay, Remix

Toggle unrelated state to see that useMemo keeps cached value until keyword dependency changes.

Common misunderstanding:

  1. useMemo is not automatically faster in every case.
  2. It adds its own dependency tracking/caching overhead.
  3. Overusing it can reduce readability more than it helps performance.

5. Combining all three effectively

A common production pattern:

  1. Wrap heavy child in React.memo.
  2. Pass stable callbacks via useCallback.
  3. Build derived collections via useMemo.

Example:

const Row = React.memo(function Row({ item, onSelect }) {
  return <button onClick={() => onSelect(item.id)}>{item.name}</button>;
});
 
function ProductList({ products, keyword }) {
  const filtered = useMemo(
    () => products.filter((p) => p.name.includes(keyword)),
    [products, keyword],
  );
 
  const handleSelect = useCallback((id: string) => {
    console.log("select", id);
  }, []);
 
  return (
    <div>
      {filtered.map((item) => (
        <Row key={item.id} item={item} onSelect={handleSelect} />
      ))}
    </div>
  );
}

6. When NOT to memoize

  1. Tiny components with low render cost.
  2. No profiling evidence of a bottleneck.
  3. Dependencies change so often cache hit rate is near zero.
  4. Team readability/maintenance cost becomes too high.

Practical rule:

  1. Profile first.
  2. Optimize real hotspots.
  3. Measure again after optimization.

Wrap-up

React.memo, useCallback, and useMemo are powerful together when used deliberately.

Remember:

  1. React.memo optimizes component render.
  2. useCallback optimizes function prop identity.
  3. useMemo optimizes computed values.

Performance work in React should start from measurements, not intuition.

2026 © @hoag/blog.