Step 0: Measure first
Do not optimize what you have not profiled. Open React DevTools Profiler, reproduce the slow interaction, and look at what is re-rendering and how long each render takes.
1. Eliminate unnecessary re-renders
The most common issue. A parent re-renders and all children re-render even if their props did not change.
// Before: re-renders on every parent render
function ExpensiveList({ items }: { items: Item[] }) {
return items.map(item => <ExpensiveRow key={item.id} item={item} />);
}
// After: only re-renders when items actually change
const ExpensiveList = memo(function ExpensiveList({ items }: { items: Item[] }) {
return items.map(item => <ExpensiveRow key={item.id} item={item} />);
});
But memo only works if the props are referentially stable. If you pass a new object or array every render, memo is useless.
2. Stabilize callbacks and objects
// Bad: new function on every render, breaks memo on children
<Button onClick={() => handleClick(id)} />
// Good: stable reference
const handleButtonClick = useCallback(() => handleClick(id), [id]);
<Button onClick={handleButtonClick} />
Same for objects passed as props — use useMemo to keep the reference stable.
3. Move state down
If a piece of state only affects one subtree, move it into that subtree. A text input's value does not need to live in the page-level component.
4. Virtualize long lists
Rendering 1,000 DOM nodes when only 20 are visible is pure waste. Use @tanstack/react-virtual:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
5. Code-split heavy components
const HeavyChart = lazy(() => import('./HeavyChart'));
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={data} />
</Suspense>
The chart library is only loaded when the component is rendered. Initial page load is faster.
6. Debounce expensive computations
Search inputs, filter functions, and any computation triggered by user typing should be debounced. 150ms is usually the right delay — fast enough to feel responsive, slow enough to batch keystrokes.
When to stop optimizing
When the Profiler shows render times under 16ms (one frame at 60fps). Below that threshold, further optimization is invisible to the user and adds complexity to the code.
