React Performance Optimization: From Basics to Advanced Techniques
Introduction
React applications can become slow as they grow in complexity. This comprehensive guide covers performance optimization techniques from basic memoization to advanced strategies like virtualization and code splitting.
Understanding React's Rendering Behavior
Before diving into optimizations, it's crucial to understand how React renders components.
The Rendering Process
// Every state change triggers a re-render
function App() {
const [count, setCount] = useState(0);
// This component and all its children re-render when count changes
return (
<div>
<Counter count={count} />
<ExpensiveComponent /> {/* Re-renders even if it doesn't use count */}
</div>
);
}
React DevTools Profiler
The React DevTools Profiler is your best friend for identifying performance issues.
// Wrap components to measure performance
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
<Profiler id="Navigation" onRender={onRenderCallback}>
<Navigation />
</Profiler>
Memoization Techniques
React.memo()
Prevent unnecessary re-renders by memoizing components:
// Without memo - re-renders on every parent render
const ExpensiveList = ({ items }) => {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
};
// With memo - only re-renders when props change
const MemoizedList = React.memo(({ items }) => {
console.log('MemoizedList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// Custom comparison function
const MemoizedListWithCompare = React.memo(
ExpensiveList,
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.items.length === nextProps.items.length;
}
);
useMemo()
Memoize expensive computations:
function DataProcessor({ data, filter }) {
// Without useMemo - recalculates on every render
const processedData = data
.filter(item => item.category === filter)
.map(item => ({
...item,
processed: heavyCalculation(item)
}));
// With useMemo - only recalculates when dependencies change
const optimizedData = useMemo(() => {
return data
.filter(item => item.category === filter)
.map(item => ({
...item,
processed: heavyCalculation(item)
}));
}, [data, filter]);
return <DataView data={optimizedData} />;
}
useCallback()
Prevent function recreation and unnecessary child re-renders:
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
// Without useCallback - new function on every render
const handleDelete = (id) => {
// delete logic
};
// With useCallback - stable function reference
const optimizedDelete = useCallback((id) => {
// delete logic
}, []); // No dependencies = never recreated
const handleToggle = useCallback((id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
));
}, []); // setTodos is stable, so no dependencies needed
return (
<>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={optimizedDelete}
onToggle={handleToggle}
/>
))}
</>
);
}
// TodoItem can be memoized effectively now
const TodoItem = React.memo(({ todo, onDelete, onToggle }) => {
return (
<div>
<span>{todo.text}</span>
<button onClick={() => onToggle(todo.id)}>Toggle</button>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
State Management Optimization
State Colocation
Keep state as close to where it's used as possible:
// Bad - App re-renders when form state changes
function App() {
const [formData, setFormData] = useState({});
return (
<div>
<Header />
<MainContent />
<ContactForm
formData={formData}
setFormData={setFormData}
/>
</div>
);
}
// Good - Only ContactForm re-renders
function App() {
return (
<div>
<Header />
<MainContent />
<ContactForm />
</div>
);
}
function ContactForm() {
const [formData, setFormData] = useState({});
// Form logic here
}
State Splitting
Split state to minimize re-render scope:
// Bad - Everything re-renders when any state changes
function Dashboard() {
const [state, setState] = useState({
user: null,
posts: [],
notifications: [],
settings: {}
});
return (
<>
<UserProfile user={state.user} />
<PostList posts={state.posts} />
<NotificationBell notifications={state.notifications} />
<Settings settings={state.settings} />
</>
);
}
// Good - Components only re-render when their data changes
function Dashboard() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [notifications, setNotifications] = useState([]);
const [settings, setSettings] = useState({});
return (
<>
<UserProfile user={user} />
<PostList posts={posts} />
<NotificationBell notifications={notifications} />
<Settings settings={settings} />
</>
);
}
List Virtualization
For large lists, render only visible items:
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
// Advanced: Variable size list with dynamic content
import { VariableSizeList } from 'react-window';
function DynamicVirtualList({ items }) {
const listRef = useRef();
const rowHeights = useRef({});
const getItemSize = useCallback((index) => {
return rowHeights.current[index] || 100; // Default height
}, []);
const Row = ({ index, style }) => {
const rowRef = useRef();
useEffect(() => {
if (rowRef.current) {
const height = rowRef.current.getBoundingClientRect().height;
if (rowHeights.current[index] !== height) {
rowHeights.current[index] = height;
listRef.current.resetAfterIndex(index);
}
}
}, [index]);
return (
<div ref={rowRef} style={style}>
<ItemComponent item={items[index]} />
</div>
);
};
return (
<VariableSizeList
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
}
Code Splitting and Lazy Loading
Route-based Code Splitting
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}
Component-based Code Splitting
// Lazy load heavy components
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Analytics({ data }) {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart data={data} />
</Suspense>
)}
</div>
);
}
// Progressive enhancement with error boundaries
import { ErrorBoundary } from 'react-error-boundary';
function AnalyticsWithErrorHandling({ data }) {
return (
<ErrorBoundary
fallback={<div>Failed to load chart</div>}
onError={(error) => console.error('Chart loading error:', error)}
>
<Analytics data={data} />
</ErrorBoundary>
);
}
Image Optimization
Lazy Loading Images
function LazyImage({ src, alt, ...props }) {
const [imageSrc, setImageSrc] = useState(null);
const [imageRef, setImageRef] = useState();
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
let observer;
if (imageRef) {
observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setIsIntersecting(true);
observer.unobserve(imageRef);
}
});
},
{ threshold: 0.1 }
);
observer.observe(imageRef);
}
return () => {
if (observer) observer.disconnect();
};
}, [imageRef]);
useEffect(() => {
if (isIntersecting) {
const img = new Image();
img.src = src;
img.onload = () => setImageSrc(src);
}
}, [isIntersecting, src]);
return (
<div ref={setImageRef} {...props}>
{imageSrc ? (
<img src={imageSrc} alt={alt} />
) : (
<div className="placeholder" />
)}
</div>
);
}
Responsive Images
function ResponsiveImage({ src, alt, sizes }) {
const generateSrcSet = () => {
const widths = [320, 640, 960, 1280, 1920];
return widths
.map(w => `${src}?w=${w} ${w}w`)
.join(', ');
};
return (
<img
src={`${src}?w=1280`}
srcSet={generateSrcSet()}
sizes={sizes || "(max-width: 640px) 100vw, (max-width: 960px) 50vw, 33vw"}
alt={alt}
loading="lazy"
/>
);
}
Debouncing and Throttling
Search Input Debouncing
import { useMemo, useState, useEffect } from 'react';
import debounce from 'lodash/debounce';
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const [results, setResults] = useState([]);
const debouncedSearch = useMemo(
() => debounce(async (term) => {
if (term) {
const data = await searchAPI(term);
setResults(data);
}
}, 300),
[]
);
useEffect(() => {
debouncedSearch(searchTerm);
return () => {
debouncedSearch.cancel();
};
}, [searchTerm, debouncedSearch]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<SearchResults results={results} />
</div>
);
}
Scroll Event Throttling
function useScrollPosition() {
const [scrollPosition, setScrollPosition] = useState(0);
useEffect(() => {
const updatePosition = throttle(() => {
setScrollPosition(window.pageYOffset);
}, 100);
window.addEventListener('scroll', updatePosition);
return () => window.removeEventListener('scroll', updatePosition);
}, []);
return scrollPosition;
}
// Custom throttle implementation
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
Bundle Size Optimization
Tree Shaking and Import Optimization
// Bad - imports entire library
import _ from 'lodash';
const result = _.debounce(fn, 300);
// Good - imports only what's needed
import debounce from 'lodash/debounce';
const result = debounce(fn, 300);
// Even better - use ES modules
import { debounce } from 'lodash-es';
// For libraries like date-fns
import { format, parseISO } from 'date-fns';
// Instead of
import * as dateFns from 'date-fns';
Dynamic Imports for Large Libraries
function ChartComponent({ data, type }) {
const [Chart, setChart] = useState(null);
useEffect(() => {
const loadChart = async () => {
const { default: ChartModule } = await import('chart.js/auto');
setChart(() => ChartModule);
};
loadChart();
}, []);
if (!Chart) return <div>Loading chart library...</div>;
return <Chart data={data} type={type} />;
}
Performance Monitoring
Custom Performance Hook
function useComponentPerformance(componentName) {
useEffect(() => {
const startTime = performance.now();
return () => {
const endTime = performance.now();
const renderTime = endTime - startTime;
if (renderTime > 16) { // More than one frame (60fps)
console.warn(
`${componentName} took ${renderTime.toFixed(2)}ms to render`
);
}
// Send to analytics
if (window.analytics) {
window.analytics.track('Component Performance', {
component: componentName,
renderTime: renderTime,
timestamp: new Date().toISOString()
});
}
};
}, [componentName]);
}
// Usage
function ExpensiveComponent() {
useComponentPerformance('ExpensiveComponent');
// Component logic
return <div>...</div>;
}
Best Practices Checklist
- Profile Before Optimizing: Use React DevTools Profiler to identify actual bottlenecks
- Measure Impact: Track performance metrics before and after optimizations
- Start Simple: Begin with basic optimizations like
React.memo()
before complex solutions - Avoid Premature Optimization: Not every component needs memoization
- Consider User Experience: Sometimes perceived performance is more important than actual performance
Common Pitfalls
Over-memoization
// Don't memoize everything
const SimpleComponent = React.memo(({ text }) => {
return <span>{text}</span>; // Too simple to benefit from memoization
});
// Memoize when it makes sense
const ExpensiveComponent = React.memo(({ data }) => {
const processed = expensiveCalculation(data);
return <ComplexVisualization data={processed} />;
});
Incorrect Dependency Arrays
// Bug: Missing dependency
useEffect(() => {
fetchData(userId); // userId should be in deps
}, []); // Missing userId
// Correct
useEffect(() => {
fetchData(userId);
}, [userId]);
// Use ESLint plugin for exhaustive deps
// npm install eslint-plugin-react-hooks
Conclusion
React performance optimization is about finding the right balance. Not every component needs optimization, but knowing these techniques helps you make informed decisions when performance issues arise.
Remember: measure first, optimize second, and always consider the trade-offs between performance gains and code complexity. Happy optimizing!