React Performance Optimization: From Slow Renders to 60fps
Optimize React application performance with techniques that eliminate unnecessary re-renders, reduce bundle size, and deliver a smooth 60fps user experience. Covers React.memo, useMemo, useCallback, code splitting, virtualization, profiling, and the mental model for understanding when and why React re-renders.
React re-renders components when state changes. This is not a bug — it is the core mechanism. The problem occurs when a single state change triggers re-renders across hundreds of components that did not need to update. A fast app becomes sluggish. Typing lags. Scrolling stutters.
Most React performance problems stem from three causes: unnecessary re-renders, large bundle sizes, and expensive computations on the main thread. This guide covers practical techniques for fixing each one.
The Re-Render Mental Model
React re-renders a component when:
1. Its state changes (useState, useReducer)
2. Its parent re-renders (props may or may not change)
3. A context it consumes changes (useContext)
React does NOT re-render because:
❌ A variable outside state changed
❌ A ref changed (useRef)
❌ An event handler was called (without state change)
The Cascade Problem
App (state changes)
├── Header (re-renders — child of App) ❌ unnecessary
│ └── Logo (re-renders — child of Header) ❌ unnecessary
├── Sidebar (re-renders — child of App) ❌ unnecessary
│ └── NavItems (re-renders) ❌ unnecessary
├── MainContent (re-renders — child of App) ✅ needed
│ └── UserProfile (re-renders) ✅ needed
└── Footer (re-renders — child of App) ❌ unnecessary
When App re-renders, ALL children re-render by default.
Only MainContent actually needed the update.
React.memo: Prevent Unnecessary Child Re-Renders
// ❌ Without memo: Header re-renders every time App state changes
function Header({ title }: { title: string }) {
console.log('Header rendered'); // Fires on every parent re-render
return <header><h1>{title}</h1></header>;
}
// ✅ With memo: Header only re-renders when 'title' prop actually changes
const Header = React.memo(function Header({ title }: { title: string }) {
console.log('Header rendered'); // Only fires when title changes
return <header><h1>{title}</h1></header>;
});
When to Use React.memo
| Use Memo | Skip Memo |
|---|---|
| Component renders frequently | Component rarely re-renders |
| Props rarely change | Props change on every render |
| Component is expensive to render | Component is cheap (simple HTML) |
| Component is deep in the tree | Component is the root or near root |
useMemo and useCallback: Stabilize References
// ❌ Problem: new object created every render, breaks memo
function App() {
const [count, setCount] = useState(0);
// This creates a NEW object every render
const config = { theme: 'dark', locale: 'en' };
// This creates a NEW function every render
const handleClick = () => console.log('clicked');
return (
<>
<ExpensiveList config={config} onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
</>
);
}
// ✅ Fixed: stable references prevent unnecessary re-renders
function App() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: 'dark', locale: 'en' }), []);
const handleClick = useCallback(() => console.log('clicked'), []);
return (
<>
<ExpensiveList config={config} onClick={handleClick} />
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
</>
);
}
Code Splitting: Smaller Initial Bundles
import { lazy, Suspense } from 'react';
// ❌ Eager import: entire admin panel loaded on first page
import AdminDashboard from './AdminDashboard';
// ✅ Lazy import: admin panel loaded only when needed
const AdminDashboard = lazy(() => import('./AdminDashboard'));
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/admin"
element={
<Suspense fallback={<LoadingSpinner />}>
<AdminDashboard />
</Suspense>
}
/>
</Routes>
);
}
Bundle Analysis
# Analyze what's in your bundle
npx source-map-explorer build/static/js/*.js
# Or use webpack-bundle-analyzer
# Add to webpack config: new BundleAnalyzerPlugin()
| Target | Goal |
|---|---|
| Initial JS bundle | < 200KB gzipped |
| Time to Interactive | < 3 seconds on 3G |
| Largest Contentful Paint | < 2.5 seconds |
| Cumulative Layout Shift | < 0.1 |
Virtualization: Rendering Only What’s Visible
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length, // 10,000 items
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Each row ~50px
overscan: 5, // Render 5 extra rows above/below viewport
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
width: '100%',
}}
>
<ListItem item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
// Renders ~15 rows instead of 10,000 → massive performance gain
Profiling with React DevTools
Steps to profile:
1. Install React DevTools browser extension
2. Open Profiler tab
3. Click "Record"
4. Perform the slow interaction
5. Click "Stop"
6. Review the flame graph:
- Yellow/red bars = slow renders
- Gray bars = did not re-render
- Click any bar to see why it rendered
Common findings:
- "Props changed" → stabilize with useMemo/useCallback
- "Parent rendered" → wrap child in React.memo
- "Context changed" → split context into multiple providers
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Inline objects in JSX | style={{ color: 'red' }} creates new object each render | Extract to constant or useMemo |
| Context for everything | One big context → all consumers re-render on any change | Split context by update frequency |
| State in the wrong place | State at root causes entire tree to re-render | Lift state down, not up |
| Premature optimization | React.memo on everything adds overhead | Profile first, optimize second |
Implementation Checklist
- Profile your app with React DevTools Profiler before optimizing
- Wrap expensive child components in
React.memo - Stabilize object/function props with
useMemoanduseCallback - Code-split routes and heavy components with
React.lazy - Virtualize lists with 100+ items using
@tanstack/react-virtual - Keep initial JS bundle under 200KB gzipped
- Split large contexts into separate providers by update frequency
- Move state as close to its consumers as possible
- Analyze bundle size with
source-map-explorerand eliminate unused dependencies - Monitor Core Web Vitals (LCP, CLS, FID/INP) in production