and when it hurts performance
React.memo, useMemo and useCallback are the most misunderstood performance tools in React. They get used everywhere, in the hope of making everything faster, or not at all. Both are wrong. Memoization has costs: comparison operations, memory for cached values and increased code complexity. Use it only where measured re-render problems actually exist.
Table of Contents
- 1. How React decides on re-renders
- 2. React.memo: how it works and its limits
- 3. Referential equality: why React.memo often does not help
- 4. useMemo: caching expensive calculations
- 5. useCallback: stable function references
- 6. Measuring re-renders with the React DevTools Profiler
- 7. Alternatives: state moved down, composition moved up
- 8. When memoization hurts
- 9. Memoization strategies compared
- 10. Summary
- 11. FAQ
1. How React decides on re-renders
React re-renders a component when its state changes, its props change, or its parent re-renders. The last point is the most important one for understanding React.memo: when a parent component renders, React by default renders all of its children, regardless of whether their props have changed. That is not a bug but a deliberate design decision: rendering is cheap, real DOM updates are not. The reconciler compares the new VDOM with the old one and only updates DOM nodes that actually changed.
This means not every re-render is a problem. React is optimized to perform many fast renders. A re-render only becomes a problem when the render function itself is expensive, for example because it runs a complex calculation or filters or sorts a large list, or when it occurs so frequently that it blocks the main thread. Only once measurable performance problems exist is memoization the right tool. Preventive memoization, that is applying React.memo and useMemo everywhere without measured problems, is counterproductive.
2. React.memo: how it works and its limits
React.memo is a higher-order component that wraps a component and only re-renders it when its props have changed. By default the comparison is done through shallow equality: every prop is compared with the strict equality operator (===). If all props have the same reference value as in the last render, React skips rendering that component and returns the previous result. If even one prop has changed, or has a new reference even though the value is logically the same, the component re-renders.
React.memo accepts an optional second argument: a custom comparison function that decides whether props should be considered equal. This enables deep-equal comparisons or selective prop comparisons. However, this function has inverted semantics compared to shouldComponentUpdate: it returns true when the component should not re-render (props are equal), and false when it should re-render. This unintuitively inverted logic is a common source of bugs. Custom comparators should be used sparingly and only with tests.
import { memo, useMemo, useCallback, useState } from 'react';
// React.memo, only re-renders when props change by reference (shallow equality)
const ExpensiveList = memo(function ExpensiveList({
items,
onItemClick,
}: {
items: string[];
onItemClick: (item: string) => void;
}) {
console.log('ExpensiveList rendered'); // should not appear on parent re-renders
return (
<ul>
{items.map(item => (
<li key={item} onClick={() => onItemClick(item)}>{item}</li>
))}
</ul>
);
});
function Parent() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState('');
// WITHOUT useMemo: new array on every parent render → memo is bypassed
const filteredItems = useMemo(
() => allItems.filter(item => item.includes(filter)),
[filter] // stable reference when filter does not change
);
// WITHOUT useCallback: new function reference every render → memo bypassed
const handleItemClick = useCallback((item: string) => {
console.log('Clicked:', item);
}, []); // stable reference, no dependencies
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<input value={filter} onChange={e => setFilter(e.target.value)} />
{/* memo works: items and handler have stable references when count changes */}
<ExpensiveList items={filteredItems} onItemClick={handleItemClick} />
</div>
);
}
3. Referential equality: why React.memo often does not help
The most common reason React.memo does not work: props with new references on every render. In JavaScript, every expression like [], {} or () => {} creates a new reference, even if the content and behavior are identical. If a component passes its memoized child an array that is newly created on every render (<Child items={data.filter(...)}/>), the child gets a new reference every time. React.memo compares the reference, not the content, so the component re-renders on every parent render despite React.memo.
This problem affects three kinds of props: objects (style={{color: 'red'}} is a new object every time), arrays (inline filter(), map()) and functions (inline arrow functions as event handlers). The fix for objects and arrays is useMemo, the fix for functions is useCallback. But: if React.memo is used without stabilizing the prop references, you get costs without benefit, the shallow comparison is still performed and fails, because every reference is new. That is worse than no React.memo at all.
4. useMemo: caching expensive calculations
useMemo caches the result of a calculation between renders. It runs the supplied function on the first render and stores the result. On every subsequent render it compares the dependency array with the previous render. If all dependencies have the same reference, it returns the cached value without running the function again. If a dependency has changed, it runs the function again and stores the new result.
useMemo has two legitimate use cases: first, caching expensive calculations that would otherwise be re-run on every render and cost measurable time, for example filtering a list with thousands of entries or computing a graph layout. Second, producing stable references for arrays and objects that are passed as props to memoized child components. For everything else, simple calculations, string concatenations, accessing object properties, useMemo is overkill: comparing dependencies and managing the cache costs more than the calculation itself.
5. useCallback: stable function references
useCallback is at its core identical to useMemo, but specifically for functions: instead of useMemo(() => () => doSomething(), [dep]) you write the more compact useCallback(() => doSomething(), [dep]). The main use case: event handler functions that are passed as props to memoized child components. If the handler is recreated on every render, it bypasses React.memo. useCallback produces a stable reference that only changes when the dependencies change.
An important pattern related to useCallback: if the function accesses state that changes frequently, that state must be included in the dependency array. That causes the function reference to change on every state change, eliminating the benefit of useCallback. The solution: use the updater pattern of the state setter functions (setState(prev => prev + 1) instead of setState(count + 1)). The updater pattern needs no dependency on the current state value because the current value is passed in as an argument.
import { memo, useMemo, useCallback, useState, useRef } from 'react';
// Measuring if memoization is actually helping
function useRenderCount(label: string) {
const count = useRef(0);
count.current += 1;
console.log(`${label} rendered ${count.current} times`);
}
const SortedTable = memo(function SortedTable({
data,
onSort,
}: {
data: { id: number; name: string; value: number }[];
onSort: (column: string) => void;
}) {
useRenderCount('SortedTable'); // verify memo is actually working
return (
<table>
<thead>
<tr>
<th onClick={() => onSort('name')}>Name</th>
<th onClick={() => onSort('value')}>Value</th>
</tr>
</thead>
<tbody>
{data.map(row => <tr key={row.id}><td>{row.name}</td><td>{row.value}</td></tr>)}
</tbody>
</table>
);
});
function Dashboard({ rawData }: { rawData: { id: number; name: string; value: number }[] }) {
const [sortColumn, setSortColumn] = useState('name');
const [theme, setTheme] = useState('light'); // changes should NOT re-render SortedTable
// useMemo: expensive sort cached by sortColumn, not by theme
const sortedData = useMemo(
() => [...rawData].sort((a, b) => a[sortColumn] > b[sortColumn] ? 1 : -1),
[rawData, sortColumn]
);
// useCallback: stable reference, memo works even when theme changes
const handleSort = useCallback((column: string) => {
setSortColumn(column);
}, []); // no deps: uses setSortColumn which is always stable
return (
<div data-theme={theme}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>Theme</button>
{/* SortedTable should NOT re-render when theme toggles */}
<SortedTable data={sortedData} onSort={handleSort} />
</div>
);
}
6. Measuring re-renders with the React DevTools Profiler
Before applying memoization, the problem must be measured. The most important tool: the React DevTools Profiler. For every render, it shows which components re-rendered and why, because of a prop change, a state change, or a parent render. The flame graph shows the render duration of every component. Red bars mark the slowest renders. The "Why did this render?" feature in the Profiler gives the reason for every component: which prop or which state changed.
A second useful tool: the browser extension react-scan or the package @welldone-software/why-did-you-render. The latter emits a console warning for every unnecessary re-render, that is when props are referentially equal but the component renders anyway, along with the cause. This uncovers cases where React.memo is used but still re-renders every time because a prop gets a new reference. Only with these measurement tools can you make informed decisions about where memoization really helps.
7. Alternatives: state moved down, composition moved up
Before applying React.memo, it is worth checking whether structural alternatives solve the re-render problem more elegantly. The first pattern: moving state down (colocation). If a state update only affects a small part of the UI, that state can be moved into the affected component. That way only that component re-renders on state changes, not the entire parent component along with all its children. No React.memo needed, no useMemo, no added complexity.
The second pattern: composition (children as a prop). If a component re-renders on every render and drags along expensive child components in the process, the child component can be passed in as a children prop. Since the child component is then rendered by the grandparent, which does not change, it does not re-render even when the parent does. This pattern is especially effective for expensive wrapper components such as modals, panels and list containers. It avoids memoization entirely while making the code more readable at the same time.
8. When memoization hurts
Memoization hurts in several concrete scenarios. First: when props change frequently and reliably. If a component gets new props on every render, because the parent always creates new references, React.memo performs a comparison on every render that fails, and still re-renders the component. That is worse than no React.memo at all, because the comparison costs an extra operation without ever preventing a render.
Second: when useMemo is used for trivial calculations. A calculation such as const double = useMemo(() => count * 2, [count]) is worse than const double = count * 2. The multiplication costs nanoseconds; useMemo maintains a cache, checks dependencies and keeps a value in memory. Third: when the React Compiler is in use. The React Compiler (formerly React Forget), introduced with React 19, analyzes components statically and inserts memoization automatically and precisely wherever it makes sense. Manual React.memo and useMemo become largely unnecessary as a result, and can even interfere with the compiler.
9. Memoization strategies compared
The decision of which memoization strategy to apply depends on the concrete problem. A structured comparison of the options shows when which measure makes sense.
| Measure | Solves | Cost | When to use |
|---|---|---|---|
| State colocation | Unnecessary parent renders | None | Always check first |
| children composition | Expensive children in wrappers | None | For wrapper components |
| React.memo | Renders caused by parent updates | Shallow comparison per render | Expensive component, stable props |
| useMemo | Expensive calculation / reference | Cache management, memory | Measurable computation cost |
| useCallback | New function reference | Cache management, dependency comparison | Props to memoized components |
The order of these measures is decisive: first optimize the structure (colocation, composition), then measure (Profiler, why-did-you-render), then memoize in a targeted way. Memoization without prior measurement is speculation. Memoization without stabilizing props (useMemo/useCallback) while simultaneously using React.memo is extra effort without benefit. The React Compiler in React 19 will eventually take this decision off your hands, but until then the rule is: measure first, then act.
Mironsoft
React performance analysis, memoization audit and re-render optimization
Want your React app analyzed for unnecessary re-renders?
We carry out a professional re-render analysis of your React application: Profiler evaluation, identification of expensive components and a targeted memoization strategy, with measurable before/after results.
Profiler analysis
React DevTools Profiler and why-did-you-render for all critical flows
Structural optimization
State colocation and composition before memoization, fixing root causes
Targeted memoization
React.memo, useMemo and useCallback only where measured need exists
10. Summary
React.memo, useMemo and useCallback are precise tools, not general accelerators. Every use of memoization has costs: comparison operations, cache management and code complexity. These costs are only worth paying when they are outweighed by avoided re-renders or saved computation time. Before every use comes measurement with the React DevTools Profiler and why-did-you-render. Structural alternatives, state colocation and children composition, solve many re-render problems more elegantly and without added complexity.
The three most common mistakes: React.memo without stabilizing the props (useMemo/useCallback), which makes the memo check fail every time. useMemo for trivial calculations that are faster than the cache management itself. useCallback with too many dependencies, causing the function to be recreated anyway on every relevant state change. The React Compiler in React 19 will partially automate these manual decisions, but until then the rule is: measure first, check structural alternatives, then memoize in a targeted way.
React.memo, useMemo, useCallback: the essentials at a glance
Structure first
Check state colocation and children composition before memoizing. They often solve the problem without any added complexity.
Measure first
Use the React DevTools Profiler and why-did-you-render. Only memoize where measurable re-render problems exist.
Stabilize props
React.memo without stable prop references is ineffective. useMemo for arrays/objects, useCallback for functions passed as props.
React Compiler
The React 19 Compiler memoizes automatically. Manual React.memo will become unnecessary in the long run. For now: use it in a targeted, measured way.