memo, useMemo, and useCallback: Deliberate Memoization
Learn what referential equality means for React's rendering, how memo, useMemo, and useCallback each address a specific re-render or recomputation cost, and how to decide — deliberately, not reflexively — when memoization actually helps versus when it just adds overhead.
React re-renders a component whenever its state changes or its parent re-renders, and by default that re-render recreates every value, object, and function inside the component from scratch. Most of the time this is cheap enough that it doesn't matter. Memoization — memo, useMemo, and useCallback — exists for the specific, narrower cases where recreating something is genuinely expensive or where a stable reference is needed to avoid cascading re-renders, and reaching for it outside those cases tends to add complexity without any real benefit.
Referential Equality: The Core Problem
In JavaScript, two objects, arrays, or functions with identical contents are not ===-equal unless they're the literal same reference. Every time a component function runs, any object literal, array literal, or function defined inside it is a brand-new reference, even if its contents look exactly the same as the one from the previous render.
This matters in React because several of its optimizations compare props by reference, not by deep equality, for performance reasons. A child component wrapped in memo that receives a new function reference as a prop on every parent render will re-render every time, even if that function does exactly the same thing every time — memo's shallow comparison sees a 'different' prop because the reference changed.
useMemo and useCallback exist specifically to give you control over this: instead of letting a value or function be recreated fresh on every render, they let you keep the same reference across renders as long as the underlying inputs haven't changed.
memo, useMemo, and useCallback in Practice
memo wraps a component and tells React to skip re-rendering it if its props are shallow-equal to what they were last time, even if its parent re-rendered. This only helps if the component actually receives the same prop values across renders — if any prop is a fresh object, array, or function reference each time, memo's comparison always finds a difference and re-renders anyway.
useMemo(calculation, deps) caches the result of a calculation across renders, only re-running it when one of the listed dependencies changes. It's for expensive computations — filtering or sorting a large array, running a heavy aggregation — where recomputing on every render would be genuinely wasteful, not for trivial calculations that cost far less than the memoization bookkeeping itself.
useCallback(fn, deps) is functionally useMemo specialized for functions: it returns the same function reference across renders as long as its dependencies haven't changed. Its primary purpose is keeping a callback's reference stable so that a memoized child receiving it as a prop doesn't see a 'new' prop and re-render unnecessarily on every parent render.
When Memoization Helps and When It Hurts
Memoization helps in two fairly narrow situations: an actually expensive calculation that shouldn't be redone on every render, and a stable reference that a memoized child genuinely needs to avoid re-rendering. Outside of those, wrapping components in memo and every function in useCallback 'just in case' adds real cost — the comparison logic itself takes time and memory on every render, and it makes the code harder to read for a saving that, in most components, doesn't exist.
A common trap is memoizing a component with memo but then passing it inline object or function props from the parent without also memoizing those with useMemo or useCallback — the memo wrapper does nothing in that case, because a fresh reference defeats the shallow comparison on every single render, so you've paid the comparison cost for zero benefit.
The default, correct posture for most components is to not memoize at all. Reach for memoization only after noticing an actual, measurable performance problem — a component that's visibly slow to re-render, a calculation that's provably expensive — rather than applying it preemptively across a codebase on the assumption that it must be helping.
The React Compiler and the Future of Memoization
The React Compiler is a build-time tool that analyzes component code and automatically inserts the equivalent of memo, useMemo, and useCallback wherever it determines they'd help, without requiring the developer to write any of those calls by hand. Components written in idiomatic React — following the rules of React, like not mutating props or state directly — can be compiled this way with no manual memoization at all, and the compiler's automatic decisions are generally at least as precise as a developer's manual ones, since it can see the actual data flow rather than guessing.
This shifts the practical advice: in a codebase using the React Compiler, manually written useMemo, useCallback, and memo calls become largely redundant, and the compiler-aware strategy is to write plain, straightforward component code and let the compiler handle memoization, reaching for manual memoization only in edge cases the compiler can't cover (such as a value that must be referentially stable across a boundary the compiler doesn't optimize, like a context value or a non-React library callback).
In codebases without the compiler, manual memoization remains the tool, but the same underlying principle holds either way: memoization is an optimization for measured or clearly understood cost, not a default habit. Whether it's the compiler or a developer deciding, the right amount of memoization is the amount an actual re-render or computation cost calls for — no more.
memo + useCallback working together
handleSelect keeps the same reference across every render of List because its dependency array is empty, so ExpensiveRow's memo comparison sees an unchanged onSelect prop and can skip re-rendering rows that didn't change. Without useCallback here, a fresh handleSelect function on every render of List would defeat ExpensiveRow's memo wrapper entirely.
const ExpensiveRow = memo(function ExpensiveRow({ item, onSelect }: { item: Item; onSelect: (id: string) => void }) {
// ... expensive rendering logic
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
function List({ items }: { items: Item[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []); // stable reference across renders
return <ul>{items.map(item => <ExpensiveRow key={item.id} item={item} onSelect={handleSelect} />)}</ul>;
}Code Examples
The reduce only re-runs when the orders array reference actually changes, rather than on every render of Dashboard for unrelated reasons (like a sibling state update). For a small list this wouldn't matter, but for a large, frequently-passed-down orders array it avoids repeated, wasted work.
const totalRevenue = useMemo(() => orders.reduce(...), [orders]);
Even though Row is wrapped in memo, config={{ color: "blue" }} creates a brand-new object reference every time List re-renders (for example, when count changes), so memo's shallow comparison always finds a 'different' prop and re-renders Row anyway. The memo wrapper here adds a comparison cost for zero actual benefit.
Common mistake: Wrapping a component in memo without also stabilizing the object/array/function props passed to it makes the memo wrapper a no-op — worse than doing nothing, since the comparison itself still costs time on every render.
function Dashboard({ orders }: { orders: Order[] }) {
const totalRevenue = useMemo(() => {
return orders.reduce((sum, o) => sum + o.amount, 0);
}, [orders]);
return <p>Total revenue: {totalRevenue}</p>;
}const Row = memo(function Row({ config }: { config: { color: string } }) {
return <li style={{ color: config.color }}>Item</li>;
});
function List() {
const [count, setCount] = useState(0);
// New object literal on every render defeats memo entirely:
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<Row config={{ color: "blue" }} />
</div>
);
}When a memoized child actually skips re-rendering
memo only pays off when every prop it receives is genuinely stable across renders, not just semantically 'the same'.
Parent re-renders
|
v
Are all props to memoized child reference-equal to last time?
|-- yes --> child render skipped (memo helps)
|-- no --> child re-renders anyway (memo did nothing but cost a comparison)Important Rules
Default to no memoization; add memo, useMemo, or useCallback only after identifying an actual, measured cost.
Pairing memo on a child with unstabilized inline object/array/function props from the parent defeats the memo wrapper entirely.
Use useMemo for genuinely expensive calculations, not for trivial ones where the memoization overhead outweighs the savings.
Use useCallback primarily to stabilize a function reference that's passed as a prop to a memoized child (or used as a dependency elsewhere).
In a codebase with the React Compiler enabled, prefer writing plain component code and letting the compiler insert memoization automatically rather than hand-writing it.
Where this shows up
Large data tables and lists — hundreds or thousands of rows, each with meaningful per-row rendering cost — are the classic legitimate case for memo on the row component paired with useCallback for any handler passed down to it. Expensive derived analytics (aggregating, sorting, or filtering a large dataset) are the classic case for useMemo. Outside cases like these, most components in a typical CRUD application render cheaply enough that memoization is unnecessary complexity rather than a meaningful optimization, which is why the deliberate, measure-first approach — and increasingly, the React Compiler's automatic approach — is preferred over memoizing reflexively.
function Total({ price, quantity }: { price: number; quantity: number }) { const total = useMemo(() => price * quantity, [price, quantity]); return <p>{total}</p>; } function Total({ price, quantity }: { price: number; quantity: number }) { const total = price * quantity; return <p>{total}</p>; }
A single multiplication is far cheaper than the bookkeeping useMemo itself performs (storing the cached value, comparing dependencies each render). Memoizing trivial calculations like this adds overhead and code complexity with no measurable benefit.
[concept] Why can a memoized component still re-render on every parent render even though its props 'look the same'? A. memo doesn't actually compare props at all B. If any prop is an object, array, or function created fresh on each render, its reference differs even when its contents are identical, and memo compares by reference C. memo only works on class components D. The component's key prop changed
B. If any prop is an object, array, or function created fresh on each render, its reference differs even when its contents are identical, and memo compares by reference — memo's default comparison is shallow, meaning it checks reference equality for object/array/function props. A freshly created object or function is never reference-equal to the previous render's, even with identical contents, so memo re-renders anyway unless that value is itself memoized.
[concept] What is the recommended default approach to memoization in a typical component? A. Wrap every component in memo and every function in useCallback as a matter of habit B. Avoid memoization by default and add it deliberately only after identifying an actual, measured cost C. Use useMemo for every value, no matter how trivial to compute D. Never use memoization under any circumstances
B. Avoid memoization by default and add it deliberately only after identifying an actual, measured cost — Memoization has a real cost (comparison and bookkeeping) and is only worth it for genuinely expensive calculations or references that need to stay stable for a memoized child. Applying it reflexively adds complexity without a corresponding benefit in most components.
[concept] How does the React Compiler change the practical advice around manual memoization? A. It makes memo, useMemo, and useCallback mandatory in every component B. It has no effect on memoization at all C. It can automatically insert the equivalent of memo/useMemo/useCallback at build time, so idiomatic component code often needs little or no manual memoization D. It replaces useState entirely
C. It can automatically insert the equivalent of memo/useMemo/useCallback at build time, so idiomatic component code often needs little or no manual memoization — The React Compiler analyzes component code and automatically applies memoization where it would help, which means codebases using it can generally write plain component code and rely on the compiler rather than hand-writing memo/useMemo/useCallback everywhere.
A ProductGrid component renders a memoized ProductCard for each of 500 products and passes each one an onAddToCart callback defined inline in ProductGrid. Identify why memo isn't helping here, and fix it.
function ProductGrid({ products }: { products: Product[] }) { const handleAddToCart = useCallback((id: string) => { // add to cart logic }, []); return ( <div> {products.map(p => ( <ProductCard key={p.id} product={p} onAddToCart={handleAddToCart} /> ))} </div> ); }
An inline arrow function defined in ProductGrid's body is a new reference on every render — wrap it in useCallback so ProductCard's memo comparison can actually find it unchanged.