AlgoInsight

Free lesson · React Deep Dive

Memoization: The Actual Semantics

Medium10 min readreactjavascriptfrontend

Lesson 17.1 - Memoization: The Actual Semantics

The operational lesson. What each memoization tool actually does.

Most engineers learn useMemo, useCallback, and React.memo through a fuzzy "they help with performance" framing. That framing produces both under-use (engineers don't reach for them when they should) and over-use (engineers wrap everything in them prophylactically). The precise framing - what each one literally compares, what it bails out from, what it costs - produces better decisions.

This lesson covers the semantics. By the end, you'll know exactly what happens in each render when these are present, what conditions trigger their memoization to kick in vs not, and what their overhead is.

The framing for the lesson: memoization is a trade. You spend memory and dependency-comparison cost to save the computation you'd have done otherwise. Like every trade, it's only worth making when the cost saved exceeds the cost paid. The lesson establishes the trade's mechanics so you can reason about it precisely.


The Three Tools

A quick recap of what each is for:

The three address different things - a value, a reference, a render - but the underlying mechanism in each is the same: compare inputs; if they match the last invocation, return the cached result; otherwise recompute.

flowchart TB
    Tool["Memoization Tool"]
    UMemo["useMemo<br/>memoizes a VALUE"]
    UCb["useCallback<br/>memoizes a FUNCTION REFERENCE"]
    RMemo["React.memo<br/>memoizes a RENDER"]
    
    Tool --> UMemo
    Tool --> UCb
    Tool --> RMemo
    
    UMemo --> UMemoMech["Compares deps via Object.is.<br/>Same deps → return cached value.<br/>Different → recompute."]
    UCb --> UCbMech["Compares deps via Object.is.<br/>Same deps → return same function reference.<br/>Different → return new function."]
    RMemo --> RMemoMech["Compares props via shallow Object.is.<br/>Same props → skip render entirely.<br/>Different → render normally."]

    style UMemo fill:#fbbf24,stroke:#f59e0b,color:#000
    style UCb fill:#a78bfa,stroke:#8b5cf6,color:#000
    style RMemo fill:#34d399,stroke:#10b981,color:#000
A diagram of the three memoization tools and what each does. useMemo memoizes a value  - it takes a computation function and a deps array, runs the computation when deps change, and returns the cached result when they haven't. The comparison uses Object.is per dep. useCallback memoizes a function reference  - same mechanism as useMemo but specifically for keeping a function reference stable across renders. The comparison is also Object.is per dep. React.memo memoizes a render  - it wraps a component and compares the new props against the previous props using shallow Object.is on each prop key. If all props match, the component's render function isn't called; the previous render's output is reused. If any prop changed, the render proceeds normally. Each tool addresses a different unit  - value, reference, render  - but the underlying mechanism is the same: compare inputs, return cached output if they match, recompute if they don't.

Let me walk through each in detail.


useMemo - Value Memoization

The signature:

function useMemo<T>(compute: () => T, deps: DependencyList): T

The behavior:

  1. On first render, compute() runs. The result is stored on the fiber (per M15.2's hooks linked list).
  2. On subsequent renders, React compares the new deps array to the previous deps array, element by element, using Object.is.
  3. If all elements match, React returns the cached value. compute() does not run.
  4. If any element differs, compute() runs again. The new result is stored.

The comparison cost: O(n) where n is the length of the deps array. For typical deps arrays (1-5 elements), this is microseconds. Plus the function call overhead and one closure allocation per render.

The example:

function Dashboard({ users, filter }) {
  const filteredUsers = useMemo(
    () => users.filter(u => u.name.includes(filter)),
    [users, filter]
  )
  return <UserList users={filteredUsers} />
}

If users and filter are the same as last render (by reference for users, by value for filter), filteredUsers is the cached result from before. The users.filter call doesn't happen. If either changed, the filter runs.

A subtle but important point: useMemo's cache is per-component-instance and lasts only as long as the component is mounted. When the component unmounts, the cache is freed. When it remounts (per M14.5), the cache starts empty. It's not a global cache; it's per-fiber storage.

A misconception worth dispelling: useMemo is not a guarantee. React reserves the right to throw away the cache and recompute even when deps haven't changed (e.g., under memory pressure). For correctness, your compute function must be a pure function of its deps. Don't rely on useMemo for non-performance behavior (like "this expensive setup should only run once").


useCallback - Function Reference Memoization

The signature:

function useCallback<T extends Function>(callback: T, deps: DependencyList): T

Conceptually: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps). The cleaner syntax exists because functions are such a common thing to memoize for reference stability.

The behavior:

  1. On first render, the function reference is stored on the fiber.
  2. On subsequent renders, React compares deps via Object.is.
  3. If deps match, React returns the cached function. The new function in JSX is discarded.
  4. If deps differ, React stores the new function and returns it.

The example:

function Parent({ items }) {
  const handleClick = useCallback((id: string) => {
    deleteItem(id)
  }, [])  // empty deps  - same reference every render
  
  return <List items={items} onItemClick={handleClick} />
}

handleClick has the same reference across every render of Parent. If List is wrapped in React.memo, this reference stability lets the memoization actually work.

Critical detail about closures. A useCallback-wrapped function still closes over the values from the render in which it was created. If the deps don't include something the function reads, you can get stale closures (M13.4):

function Search({ items }) {
  const [query, setQuery] = useState('')
  
  const search = useCallback(() => {
    return items.filter(i => i.name.includes(query))  // closes over query
  }, [items])  // ❌ query not in deps  - stale closure
  
  return <SearchResult onSearch={search} />
}

The function references stay stable (good for React.memo), but the captured query is from when the callback was last memoized - possibly an old value. The lint rule (M11.4) catches this; the fix is including query in deps:

const search = useCallback(() => {
  return items.filter(i => i.name.includes(query))
}, [items, query])

Now the function reference changes when query changes, which is correct - but it means the reference isn't stable across all renders, defeating part of the memoization's purpose. Such are the trade-offs of manual memoization.


React.memo - Render Memoization

The signature:

function memo<P>(
  Component: React.ComponentType<P>,
  compare?: (prevProps: P, nextProps: P) => boolean
): React.ComponentType<P>

The behavior:

  1. The wrapped component remembers the last props it received.
  2. On each invocation, React compares the new props to the cached previous props.
  3. Default comparison: shallow. Each prop is compared using Object.is.
  4. If all props match, the previous render's output is reused. The component's render function is not called.
  5. If any prop differs, the render proceeds normally.

A subtle detail: React.memo does not stop the cascade rule entirely - it just adds a check. If a parent re-renders, React asks the memoized child "should I render you?" by comparing props. If props match, the answer is no. The child's render is skipped, but its subtree is also skipped (because the child didn't render, so its children don't render).

The example:

const ExpensiveChild = React.memo(function ExpensiveChild({ data, onClick }) {
  // ... expensive rendering
  return <div>...</div>
})

function Parent({ items }) {
  const handleClick = useCallback(() => doSomething(), [])
  return <ExpensiveChild data={items} onClick={handleClick} />
}

If items reference is the same and handleClick reference is the same (thanks to useCallback), the memo check returns "props equal" and ExpensiveChild is skipped on Parent's re-renders.

The cost of React.memo. Every time the parent re-renders, the memo check runs. The check is O(p) where p is the number of props. For typical components (2-5 props), the check is microseconds. But it's nonzero. If the props always change anyway (the memo check always returns "different"), you're paying the comparison cost for no benefit.

Custom comparison. The optional second argument lets you provide a custom comparator:

const Component = React.memo(
  function Component({ data, options }) { ... },
  (prev, next) => deepEqual(prev.data, next.data) && shallowEqual(prev.options, next.options)
)

Return true to skip render (props "equal"); return false to render. This is useful when you have specific knowledge about how props change. Deep equality, version numbers, content hashes - anything you can compute cheaper than re-rendering.

A subtle note: custom comparators have their own cost (the deep comparison). For very expensive components, the cost can be justified. For most components, the default shallow comparison is what you want.


What "Shallow Comparison" Means

A specific detail worth nailing down. React.memo's default uses shallow comparison via Object.is. The mechanism:

function shallowEqual(prev, next) {
  if (Object.is(prev, next)) return true  // same reference
  if (typeof prev !== 'object' || typeof next !== 'object') return false
  if (prev === null || next === null) return false
  
  const prevKeys = Object.keys(prev)
  const nextKeys = Object.keys(next)
  
  if (prevKeys.length !== nextKeys.length) return false
  
  for (const key of prevKeys) {
    if (!Object.is(prev[key], next[key])) return false
  }
  
  return true
}

So for React.memo, the comparison is: are all top-level prop values Object.is-equal?

For primitive props (strings, numbers, booleans), Object.is compares by value. Same values → equal.

For object/array/function props, Object.is compares by reference. Same reference → equal. Different reference → not equal, even if contents look the same.

This is the rule that makes inline object literals defeat memoization:

<MemoizedChild config={{ value: 5 }} />

Each render creates a new { value: 5 } object. The reference differs from the previous render's object. React.memo's comparison returns "different" - render proceeds. The memoization didn't help.

The same applies to inline arrow functions, derived arrays from .filter()/.map(), anything that produces a fresh object/function/array each render. For memoization to work, the references have to be stable. That's where useCallback and useMemo come in - they produce stable references for memoized children.


The Cost of Memoization

A specific topic that's underemphasized. Memoization isn't free. The costs:

1. Memory. Each useMemo/useCallback stores a cached value on the fiber. For long-running apps with many components, the cumulative storage isn't trivial. Usually negligible, occasionally relevant.

2. Comparison overhead. Each invocation runs the deps comparison (Object.is per dep for useMemo/useCallback; shallow prop comparison for React.memo). The comparison itself takes time. For trivial cases, the comparison is cheaper than the work it'd save; for very cheap work, the comparison costs more than skipping it would.

3. Stale closure risk. useCallback with incorrect deps captures stale values. The lint rule catches the common cases, but the risk is real.

4. Coupling. Memoization assumes stable references upstream. A memoized child depends on its parent passing stable props. When the parent's contract changes, the memoization silently breaks. The coupling is invisible until it isn't.

5. Code complexity. Memoization makes code denser and harder to read. The "noise floor" of a codebase littered with useMemo/useCallback is high.

These costs are usually small. But they exist. For optimization to be net-positive, the savings have to exceed them. For trivial computations or fast renders, the savings don't exceed them. That's the next lesson's territory.


A Specific Detail: useMemo for Reference Stability Without Computation

A non-obvious use of useMemo worth knowing. Sometimes you want to memoize an object literal for reference stability rather than for skipping a computation:

const config = useMemo(
  () => ({ debug: false, timeout: 5000 }),
  []
)  // empty deps  - same reference forever

The object isn't expensive to create. The motivation isn't computation savings; it's keeping the reference stable across renders so that downstream memoization works.

This is a legitimate pattern when you have a memoized child and you want to pass it a stable object prop. It's pointless if there's no memoized child (the reference instability wouldn't matter).

The same applies to arrays:

const allowedTypes = useMemo(() => ['png', 'jpg', 'gif'], [])

Or to derived but reference-sensitive values:

const sortedItems = useMemo(
  () => items.sort((a, b) => a.name.localeCompare(b.name)),
  [items]
)

For these patterns, useMemo's benefit is reference stability, not computation skipping. Worth knowing as a distinct use case.


Custom Equality and When to Reach For It

React.memo's default shallow comparison handles most cases. The cases where custom comparison is justified:

Components with complex props you control. You know that two prop objects with the same id represent the same logical thing, so you can compare just id.

Components that receive data with version markers. Compare versions instead of doing structural comparison.

Lists of items that should re-render based on specific changes only. Custom comparator that checks just the relevant fields.

Don't reach for custom comparison casually. Most use cases are better served by structuring the data so the default comparison works (stable references via useMemo, atomic values that compare by value, etc.).

When you do use custom comparison, write tests. The comparator is a piece of logic that's easy to get wrong and hard to debug visually.


What I Want You to Take From This Lesson

Six things.

One: useMemo memoizes a value. useCallback memoizes a function reference. React.memo memoizes a render. Three units, same underlying mechanism.

Two: All three compare inputs to decide whether to use the cache. useMemo and useCallback compare deps with Object.is; React.memo compares props shallowly.

Three: Inline objects, arrays, and functions are new references each render. They defeat React.memo unless paired with useMemo/useCallback for stability.

Four: Memoization has costs - memory, comparison overhead, stale-closure risk, coupling, complexity. The trade is real.

Five: useMemo can be used for reference stability without computation savings. When the downstream cares about reference equality.

Six: Custom equality for React.memo is a legitimate but rare tool. Most cases are better served by structuring data for the default.


Try It

Exercise: Warmup (20 minutes). Build a parent with a memoized child. Pass an inline object as a prop. Profile - verify the memo doesn't help. Switch to a useMemo-stabilized object. Verify the memo now skips renders.

Exercise: Core (30 minutes). Build the useCallback stale-closure bug. Verify the bug. Apply the lint rule's suggested fix. Verify the fix works but the reference now changes more often.

Exercise: Core (30 minutes). Build a React.memo with a custom comparator. Write tests for the comparator. Verify it works correctly for the cases you care about.

Exercise: Stretch (45 minutes). Read React's source for React.memo and the shallowEqual function. Verify the algorithm matches what this lesson described.


What's Next

Lesson 17.2 covers when memoization actually helps versus when it's net-negative. The discipline lesson that pairs with this lesson's semantics.

Back to React Deep Dive