Most React performance articles teach you to wrap everything in useMemo and React.memo. This is bad advice. Memoization has a cost — the comparison on every render — and if the value changes frequently, you pay the cost without getting the benefit. Worse, memoization adds cognitive overhead that makes code harder to reason about.
Real performance work starts with measurement, not intuition. Open the React DevTools Profiler, record an interaction that feels slow, and look at what actually took time. You will almost always find one of three problems: unnecessary renders, expensive computations in the render path, or poor list virtualization.
A component re-renders when its state changes, its parent re-renders, or its context changes. The question is never "how do I prevent re-renders" — it's "does this re-render produce visible work?"
React's reconciliation is fast. A component that re-renders but produces the same JSX costs almost nothing. The expensive case is a component that re-renders AND does expensive work (fetching, sorting, formatting large datasets).
The first tool to reach for is component decomposition, not memoization:
// Slow: the entire list re-renders when filter changes
function ProductPage() {
const [filter, setFilter] = useState('all');
const products = useProducts(); // 500 items
return (
<div>
<FilterBar filter={filter} onChange={setFilter} />
{products
.filter(p => filter === 'all' || p.category === filter)
.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// Better: isolate the filter state into a child component
function ProductList({ filter }: { filter: string }) {
const products = useProducts();
const visible = useMemo(
() => products.filter(p => filter === 'all' || p.category === filter),
[products, filter]
);
return <>{visible.map(p => <ProductCard key={p.id} product={p} />)}</>;
}
By moving state down and splitting components, you limit re-render scope automatically.
useMemo is worth reaching for when:
const sortedItems = useMemo(
() => [...items].sort((a, b) => a.price - b.price),
[items]
);
If items comes from a stable source (fetched once, stored in state), this memoization is legitimate. If items is recreated on every render, the memoization is theater.
Pass callbacks to memoized children without useCallback and the memoization breaks — new function reference on every render means the child always sees a "changed" prop.
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable: no deps
The rule: use useCallback when the function is passed to a React.memo component or a hook that takes a callback in its dependency array.
Context re-renders every consumer whenever the context value changes. If you put both state and dispatch in the same context, every component reading dispatch will re-render when state changes.
Split them:
const StateContext = createContext<State | null>(null);
const DispatchContext = createContext<Dispatch<Action> | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>
{children}
</StateContext.Provider>
</DispatchContext.Provider>
);
}
Components that only dispatch actions won't re-render when state changes.
No memoization trick will save a component rendering 10,000 DOM nodes. If you have long lists, virtualize them. @tanstack/react-virtual is the current best option:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
});
return (
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(vItem => (
<div
key={vItem.key}
style={{ transform: `translateY(${vItem.start}px)` }}
>
<ItemRow item={items[vItem.index]} />
</div>
))}
</div>
</div>
);
}
With virtualization, only the visible rows exist in the DOM. Scrolling through 10,000 items costs the same as scrolling through 20.
Measure first. Memoize second. Decompose as the default.