Stop Reaching for useMemo: How Re-Renders Actually Work

Table of Contents
- The Memoization Reflex
- A Re-Render Is Not a DOM Update
- What Memoization Actually Costs
- Why Most useMemo Calls Do Nothing
- The Problems Worth Fixing
- Composition Beats Memoization
- Measuring Before Optimising
- Where Performance Actually Goes
- Common Pitfalls
- Conclusion
- Frequently Asked Questions
Key takeaway: A re-render is a function call producing a description of UI. It is usually cheap. Memoization adds comparison cost and memory on every render to avoid work that was frequently not expensive, and it fails silently when dependencies are unstable.
The Memoization Reflex
Open a React codebase of any size and you will find useMemo wrapping computations that take microseconds, useCallback around functions passed to components that do not memoize, and React.memo on components whose props change every render.
The intent is reasonable — avoid unnecessary work. The result is frequently a net loss, because each of those has a cost paid on every render to avoid a cost that was smaller.
The underlying misconception is that a re-render is inherently expensive. It generally is not. Rendering a component means calling a function that returns a description of what the UI should look like. For a typical component that is a few microseconds of object allocation and property access.
What is expensive is committing changes to the DOM, and React already avoids that when the output has not changed. The reconciliation step exists specifically to determine what actually needs updating.
This means the mental model that drives most memoization — re-render equals slow — is wrong, which is why the resulting optimisations mostly do not help. Understanding what is actually costly is the prerequisite for optimising anything.
A Re-Render Is Not a DOM Update
Three distinct phases, with very different costs:
Render. Your component function executes and returns an element tree. Pure computation, no DOM involvement. Cheap for most components.
Reconciliation. React compares the new tree against the previous one to determine what changed. Proportional to tree size, and highly optimised.
Commit. Actual DOM mutations are applied. This is the expensive phase, and it only touches what genuinely changed.
A component re-rendering and producing identical output costs the render and reconciliation phases and skips the commit entirely. That is measurable and small.
The practical implication: a parent re-rendering causes children to re-render, which sounds alarming and usually means a small amount of computation followed by React determining nothing needs to change. Preventing that re-render with memoization saves the render and reconciliation cost and adds a props comparison cost. For a simple component the comparison is comparable to the render it avoided.
Where this stops being true is when a component’s render function does something genuinely expensive, or when the tree beneath it is very large. Those are the cases worth optimising, and they are identifiable by measurement rather than by assumption.
What Memoization Actually Costs
Each memoization primitive has a price paid on every render:
useMemo stores the value and the dependency array, then compares dependencies on each render. Cost: memory for the retained value, plus the comparison. If the computation was cheaper than the comparison plus allocation, this is a loss.
useCallback allocates a closure and compares dependencies regardless. Note that it does not avoid creating the function — the function is created either way. It avoids creating a new one, which only matters if something downstream compares by reference.
React.memo performs a shallow comparison of all props on every render. For a component with many props, this comparison can exceed the render cost of a simple component.
There is also a cost that does not appear in benchmarks: memoized values are retained, which means memory that would have been collected is held for the component’s lifetime. In a long-lived application with many memoized values, this accumulates.
And a maintenance cost. Every dependency array is a correctness hazard. An omitted dependency produces a stale value — a bug that is intermittent, hard to reproduce, and frequently discovered long after the memoization was added.
Why Most useMemo Calls Do Nothing
Beyond the cost question, a large share of memoization in real codebases has no effect at all, for specific reasons.
Unstable dependencies. A dependency that is a new object or array each render means the memo never hits.
// The memo recomputes every render — options is a new object each time
const options = { includeArchived: false };
const result = useMemo(() => transform(data, options), [data, options]);
useCallback passed to a non-memoized component. The stable function reference accomplishes nothing if the receiving component re-renders regardless.
// Stable reference, and Child re-renders anyway because it is not memoized
const handleClick = useCallback(() => doThing(id), [id]);
return <Child onClick={handleClick} />;
React.memo with an object or callback prop created inline. The shallow comparison fails on the first differing reference, so the memo never prevents anything.
// memo() compares props, style is a new object every render, always re-renders
<MemoizedChild style={{ margin: 8 }} data={items} />
Memoizing a primitive computation. Comparing dependencies to avoid an arithmetic operation or a short string concatenation is strictly more work.
The pattern across these: memoization requires the entire chain to be stable to have any effect, and a single unstable link makes the whole thing decorative while retaining its cost. Auditing an existing codebase for these usually finds that most memoization falls into one of these categories.
The Problems Worth Fixing
Genuine performance problems in React applications are a short and specific list.
Long lists rendered in full. Thousands of rows produces thousands of components and DOM nodes. This is a real cost and the fix is virtualisation — rendering only the visible window. Effect size is large.
Expensive computation in render. Sorting or filtering large datasets, parsing, or heavy formatting, executed on every render. This is where useMemo is genuinely correct, and it is a small fraction of its actual usage.
Context causing broad re-renders. Every consumer of a context re-renders when its value changes. A context holding several unrelated values causes components to re-render for changes they do not use. Splitting contexts by update frequency resolves it.
Layout thrashing. Reading a layout property after a write forces synchronous reflow. In a loop this is severely expensive and is a browser behaviour rather than a React one.
Large bundles delaying interactivity. Frequently the largest real-world performance problem, and unrelated to re-renders. Code splitting addresses it.
Unnecessary effects. An effect that runs on every render doing work — a subscription, a fetch, a computation — costs more than any re-render.
Note that only one item on that list is addressed by memoization, and none of them are found by adding memoization defensively. They are found by measuring.
Composition Beats Memoization
Structural changes frequently eliminate the problem that memoization was intended to mask, without the ongoing cost.
Move state down. If state is used by one subtree, put it there. A component holding state that only its child needs re-renders itself and everything else unnecessarily.
Lift expensive subtrees out. Passing an expensive subtree as children means it is created in the parent’s parent, so the frequently-updating component re-rendering does not re-render it.
// Expensive re-renders whenever count changes
function Page() {
const [count, setCount] = useState(0);
return <div onClick={() => setCount(c => c + 1)}><Expensive /></div>;
}
// Expensive is created outside Counter, so it does not re-render
function Page() {
return <Counter><Expensive /></Counter>;
}
function Counter({ children }) {
const [count, setCount] = useState(0);
return <div onClick={() => setCount(c => c + 1)}>{children}</div>;
}
Split contexts by change frequency. A context that changes often and one that rarely changes should be separate, so consumers of the stable one are unaffected.
Use a state manager with selector-based subscriptions. Components subscribe to the specific slices they use and re-render only when those change. This addresses the context problem structurally.
Derive during render rather than storing in state. State that is computable from other state creates synchronisation work and extra renders.
The advantage of these over memoization is that they have no per-render cost and no dependency array to maintain incorrectly.
Measuring Before Optimising
Optimising without measurement is how codebases accumulate memoization that does nothing.
The profiler flame graph. Shows which components rendered and how long each took. The useful question is which components have a large self-render time, not which components rendered.
Highlight-updates in developer tools. Visualises what re-renders on interaction. Useful for spotting unexpectedly broad update propagation.
User timing marks around suspected code. Direct measurement of a specific operation, which frequently reveals that the suspected expensive computation takes a fraction of a millisecond.
Interaction latency in production. The number that actually matters — time from user action to visible response, measured on real devices.
Bundle analysis. Frequently reveals that the performance problem is download and parse time rather than rendering.
The discipline that matters: measure, change one thing, measure again, and keep the change only if the improvement is real. Memoization added without a before-and-after measurement is a guess with a permanent cost, and it is usually wrong.
Also worth noting: measure on representative hardware. A component that renders in two milliseconds on a development machine may take twenty on a mid-range phone, which changes which optimisations are worthwhile.
Where Performance Actually Goes
For a typical application, the ranked contributors to perceived slowness:
| Cause | Typical impact | Fix |
|---|---|---|
| Large JavaScript bundle | Very high | Code splitting, dependency reduction |
| Unvirtualised long lists | High | Windowing |
| Network waterfalls | High | Parallel fetching, preloading |
| Expensive render computation | Moderate | Memoize, or move off the render path |
| Layout thrashing | Moderate to severe | Batch reads and writes |
| Unnecessary re-renders | Low | Composition, occasionally memoization |
| Unoptimised images | High on media-heavy pages | Modern formats, correct sizing |
Unnecessary re-renders sit near the bottom, which is the opposite of where most optimisation attention goes. The top two entries — bundle size and unvirtualised lists — typically dominate everything else combined.
This ordering is worth internalising because it redirects effort. A day spent on code splitting usually produces a larger improvement than a week spent adding memoization, and it does not add ongoing complexity.
Common Pitfalls
Memoizing without measuring. Adds cost to avoid an unmeasured cost.
Unstable dependencies. An inline object or array in the dependency array means the memo never hits.
useCallback to a non-memoized component. No effect whatsoever.
React.memo with inline object props. Comparison always fails.
Incomplete dependency arrays. Produces stale values and intermittent bugs.
Optimising re-renders before bundle size. Wrong order by a large margin.
Measuring only on fast hardware. Hides the problems your users experience.
Conclusion
A re-render is a function call producing a description of UI, and it is usually cheap. React already avoids the expensive part — DOM mutation — when nothing changed. Memoization pays a comparison and allocation cost on every render to avoid work that was frequently smaller, and it fails silently whenever a dependency is unstable.
The genuine performance problems are elsewhere and are a short list: bundle size, unvirtualised long lists, network waterfalls, expensive computation in render, and layout thrashing. Only one of those is addressed by memoization.
Where re-renders genuinely matter, composition usually solves it better — moving state down, passing expensive subtrees as children, splitting contexts by update frequency, and using selector-based subscriptions. None of those carry a per-render cost or a dependency array to get wrong.
And measure first, on hardware resembling your users’. Memoization added without a before-and-after measurement is a permanent cost justified by a guess.
Frequently Asked Questions
Should useMemo be used at all? Yes, for genuinely expensive computations — sorting or filtering large arrays, parsing, heavy transformations — and for preserving referential stability where something downstream depends on it. That is a small fraction of typical usage.
Does the React compiler remove the need for manual memoization? Largely, where available. It applies memoization automatically based on analysis, which is more reliable than manual dependency arrays. Understanding the underlying model remains useful for diagnosing what it cannot fix.
Is useCallback needed for event handlers? Only when the receiving component is memoized or the function is a dependency of another hook. Passing a fresh function to a regular component costs an allocation and nothing else.
How is a genuine performance problem identified? Profile during the slow interaction and look for components with high self-render time, or measure the specific operation directly. If the profiler shows nothing significant, the problem is elsewhere — usually bundle size or network.
Why does everything re-render when one context value changes? Every consumer re-renders on any change to the context value. Splitting contexts by update frequency, or using a state manager with selector subscriptions, limits re-renders to components that use the changed data.
Is virtualisation always worth it for lists? Past a few hundred rows, generally yes. Below that the complexity — variable heights, scroll restoration, accessibility, find-in-page — frequently outweighs the benefit.
What single change most improves perceived performance? Usually reducing initial JavaScript. Code splitting, removing heavy dependencies, and deferring non-critical work affect time to interactivity more than any rendering optimisation.



