React Performance Optimization
Techniques for identifying and fixing performance bottlenecks in React applications.
Most React performance problems share a root cause: unnecessary re-renders and excessive JavaScript on the main thread. This guide covers the profiling workflow and the targeted fixes that actually matter — skipping the premature micro-optimisations that slow down development without improving UX.
Profile Before You Optimise
Never guess at performance problems. Open React DevTools → Profiler, record an interaction, and look at which components render, how often, and why. The "why did this render?" feature is invaluable.
React.memo: When It Helps and When It Doesn't
React.memo prevents a component from re-rendering when its props are referentially equal to the previous render. But it only helps when: (1) the component is expensive to render, and (2) its parent re-renders frequently with the same prop values.
// ❌ memo is useless here — inline objects are new references every render
function Parent() {
return <ExpensiveChild config={{ theme: 'dark' }} />;
}
// ✅ Stable reference — memo can now bail out
const config = { theme: 'dark' }; // outside component, or useMemo
function Parent() {
return <ExpensiveChild config={config} />;
}
const ExpensiveChild = React.memo(({ config }: { config: Config }) => {
// Only re-renders when config reference changes
});useMemo and useCallback: The 80/20 Rule
Both hooks have a cost: they allocate closures and run comparison logic every render. Only use them when: (1) you're passing a value to a memo'd component, (2) the computation is genuinely expensive (> 1ms), or (3) the value is a dependency of another hook.
// ✅ useMemo for expensive derivation
const sortedItems = useMemo(
() => items.slice().sort((a, b) => a.score - b.score),
[items] // only re-sort when items changes
);
// ✅ useCallback for stable event handler passed to memo'd child
const handleSelect = useCallback(
(id: string) => dispatch({ type: 'SELECT', id }),
[dispatch] // dispatch from useReducer is stable
);
// ❌ Don't bother memoising cheap values
const label = useMemo(() => `Hello, ${name}`, [name]); // wastefulVirtualisation for Long Lists
Rendering 10,000 DOM nodes is slow regardless of how well your React code is written. Virtual lists render only the visible subset — typically 20-50 items — and recycle DOM nodes as the user scrolls. Use @tanstack/react-virtual or react-window.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualiser = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72, // estimated row height in px
});
return (
<div ref={parentRef} style={{ overflow: 'auto', height: '600px' }}>
<div style={{ height: virtualiser.getTotalSize() }}>
{virtualiser.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<ItemRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}Code Splitting and Lazy Loading
The fastest code is code that never loads. Use next/dynamic (Next.js) or React.lazy for routes and heavy components that aren't needed on initial load.
// next/dynamic with no SSR for client-only heavy libraries
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
ssr: false,
loading: () => <EditorSkeleton />,
});
// Only loaded when the user opens the modal
const HeavyModal = dynamic(() => import('@/components/HeavyModal'));Concurrent Features: useTransition and useDeferredValue
React 18's concurrent features let you mark state updates as non-urgent, keeping the UI responsive during expensive renders. Use useTransition for navigation/filter updates, and useDeferredValue to debounce derived expensive renders.
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
setInputValue(query); // urgent — update input immediately
startTransition(() => {
setSearchQuery(query); // non-urgent — can be interrupted
});
}
// In the component that renders search results
const deferredQuery = useDeferredValue(searchQuery);
// Results render with the old query while new results computeWritten by
Md. Saniuzzaman Robin
Full-Stack Software Engineer