Introduction
React applications can become slow as they grow. Unnecessary re-renders, large bundle sizes, and inefficient data structures all contribute to poor performance. This guide covers the most effective optimization techniques β from basic memoization to advanced profiling β with practical examples you can apply immediately.
Performance optimization in React follows a simple principle: measure first, optimize second. The React DevTools Profiler is your best friend β it shows exactly which components re-render, why they re-render, and how long they take. Don't optimize blindly; use data to guide your decisions.
Understanding Re-Renders
When Does React Re-Render?
A component re-renders when:
- Its state changes (
useState,useReducer) - Its parent re-renders (unless memoized)
- A context it consumes changes
// This component re-renders when parent re-renders
function ExpensiveComponent({ data }) {
// Heavy computation here
const processed = processData(data);
return <div>{processed}</div>;
}
// Parent re-renders β ExpensiveComponent re-renders
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ExpensiveComponent data={staticData} /> {/* Re-renders! */}
</div>
);
}Profiling with React DevTools
// Enable Profiler in development
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} ${phase}: ${actualDuration}ms`);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
}Memoization Techniques
React.memo
React.memo prevents re-renders when props haven't changed:
// Without memo β re-renders on every parent render
function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
}
// With memo β only re-renders when items or onSelect change
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList rendered');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});Custom Comparison Function
const UserCard = React.memo(
function UserCard({ user, onClick }) {
return (
<div onClick={() => onClick(user.id)}>
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
</div>
);
},
(prevProps, nextProps) => {
// Only re-render if user data changed
return prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name;
}
);useMemo for Expensive Computations
function DataGrid({ data, sortKey, filterText }) {
// Without useMemo β recalculates on every render
// const sorted = data.sort((a, b) => a[sortKey] - b[sortKey]);
// const filtered = sorted.filter(item => item.name.includes(filterText));
// With useMemo β only recalculates when dependencies change
const processedData = useMemo(() => {
console.log('Processing data...');
const sorted = [...data].sort((a, b) => a[sortKey] - b[sortKey]);
return sorted.filter(item =>
item.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [data, sortKey, filterText]);
return (
<ul>
{processedData.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}useCallback for Stable References
function TodoList() {
const [todos, setTodos] = useState([]);
// Without useCallback β new function reference on every render
// const handleDelete = (id) => setTodos(todos.filter(t => t.id !== id));
// With useCallback β stable reference
const handleDelete = useCallback((id) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, []); // No dependencies β never changes
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
))}
</ul>
);
}
// TodoItem wrapped in React.memo won't re-render when parent re-renders
const TodoItem = React.memo(function TodoItem({ todo, onDelete }) {
console.log(`Rendering ${todo.id}`);
return (
<li>
{todo.text}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
});Code Splitting
Route-Based Splitting
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}Component-Based Splitting
// Heavy component that's only needed conditionally
const RichTextEditor = lazy(() => import('./RichTextEditor'));
function BlogPost({ isEditing }) {
return (
<div>
<h1>{post.title}</h1>
{isEditing ? (
<Suspense fallback={<div>Loading editor...</div>}>
<RichTextEditor content={post.content} />
</Suspense>
) : (
<div dangerouslySetInnerHTML={{ __html: post.content }} />
)}
</div>
);
}Dynamic Import for Libraries
// Don't import heavy libraries at the top level
// import { Chart } from 'chart.js'; // BAD: always loaded
// Import dynamically when needed
async function renderChart(data) {
const { Chart } = await import('chart.js');
const chart = new Chart(canvasRef.current, {
type: 'bar',
data: data,
});
return chart;
}Virtualization for Large Lists
Using react-window
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
);
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
}Using react-virtuoso
import { Virtuoso } from 'react-virtuoso';
function UserList({ users }) {
return (
<Virtuoso
style={{ height: 600 }}
totalCount={users.length}
itemContent={(index) => (
<div className="user-row">
<img src={users[index].avatar} alt="" />
<span>{users[index].name}</span>
</div>
)}
/>
);
}State Management Optimization
Keep State Local
// BAD: Global state for local UI
const [isModalOpen, setIsModalOpen] = useState(false); // In parent
// GOOD: Keep UI state local to component
function Modal() {
const [isOpen, setIsOpen] = useState(false);
// ...
}Avoid Derived State
// BAD: Storing derived data in state
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]); // Redundant!
useEffect(() => {
setFilteredItems(items.filter(item => item.active));
}, [items, filter]);
// GOOD: Compute derived data during render
const [items, setItems] = useState([]);
const filteredItems = useMemo(
() => items.filter(item => item.active),
[items]
);Use useReducer for Complex State
function todosReducer(state, action) {
switch (action.type) {
case 'add':
return [...state, action.todo];
case 'toggle':
return state.map(todo =>
todo.id === action.id ? { ...todo, done: !todo.done } : todo
);
case 'delete':
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, []);
// ...
}DOM Optimization
Avoid Layout Thrashing
// BAD: Reading and writing in alternation
function updateElements() {
elements.forEach(el => {
const height = el.offsetHeight; // Read (forces layout)
el.style.height = height * 2 + 'px'; // Write
});
}
// GOOD: Batch reads and writes
function updateElements() {
const heights = elements.map(el => el.offsetHeight); // All reads
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // All writes
});
}Use CSS for Animations
// BAD: JavaScript animations cause re-renders
function AnimatedBox() {
const [position, setPosition] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setPosition(p => p + 1); // Re-renders every frame
}, 16);
return () => clearInterval(interval);
}, []);
return <div style={{ transform: `translateX(${position}px)` }} />;
}
// GOOD: CSS animations don't cause re-renders
function AnimatedBox() {
return <div className="slide-in" />;
}
// CSS
// .slide-in {
// animation: slide 1s ease-in-out;
// }
// @keyframes slide {
// from { transform: translateX(0); }
// to { transform: translateX(100px); }
// }Image Optimization
// Lazy load images
function OptimizedImage({ src, alt }) {
const imgRef = useRef();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isVisible ? src : ''}
alt={alt}
loading="lazy"
decoding="async"
/>
);
}Bundle Analysis and Tree Shaking
# Analyze bundle size with webpack
npx webpack-bundle-analyzer build/static/js/*.map
# Or with Vite
npx vite-bundle-visualizer
# Check for duplicate dependencies
npx depcheck
npx npm-checkCommon bundle issues to look for:
- Duplicate dependencies: Multiple versions of the same library bundled (e.g., two versions of lodash)
- Large libraries: Replace heavy libraries with lighter alternatives (Moment.js β date-fns or dayjs, Lodash β lodash-es or native methods)
- Unused code: Code that wasn't tree-shaken due to side effects or incorrect import patterns
- Polyfills: Unnecessary polyfills targeting browsers you don't support
// BAD: Imports entire lodash library (~70KB gzipped)
import _ from 'lodash';
_.debounce(fn, 300);
// GOOD: Import only what you need (~1KB gzipped)
import debounce from 'lodash/debounce';
debounce(fn, 300);
// EVEN BETTER: Use native or lightweight alternatives
// For simple cases, write your own debounce
function debounce(fn, delay) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}For Vite projects, ensure build.rollupOptions.output.manualChunks splits vendor code from application code so that vendor changes don't invalidate the entire bundle cache.
Server Components and Data Fetching
React Server Components fundamentally change the performance equation by moving data fetching to the server. Instead of client-side fetching with loading states and waterfalls, server components fetch data directly and send rendered HTML to the client.
// Server Component: zero client-side JavaScript for data fetching
async function ProductPage({ id }: { id: string }) {
// These run in parallel on the server
const [product, reviews, recommendations] = await Promise.all([
db.products.findUnique({ where: { id } }),
db.reviews.findMany({ where: { productId: id } }),
getRecommendations(id)
]);
return (
<div>
<ProductDetails product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsList reviews={reviews} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationsGrid items={recommendations} />
</Suspense>
</div>
);
}The performance benefit is dramatic: no client-side JavaScript for data fetching, no loading spinners for initial content, and no request waterfalls. The server fetches all data in a single round trip to the database, then streams the rendered HTML to the client.
Avoiding Data Fetching Waterfalls
A common performance anti-pattern is sequential data fetching in nested components:
// BAD: Sequential waterfalls
async function Page() {
const user = await getUser(); // 200ms
const posts = await getPosts(); // 200ms
const comments = await getComments(); // 200ms
// Total: 600ms
}
// GOOD: Parallel fetching
async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments()
]);
// Total: 200ms (longest individual request)
}Best Practices
- Measure before optimizing β Use React DevTools Profiler
- Memo expensive components β
React.memofor components with stable props - Memoize computations β
useMemofor expensive calculations - Stabilize callbacks β
useCallbackwhen passing to memoized children - Code split by route β
lazy()andSuspensefor route-based splitting - Virtualize long lists β
react-windoworreact-virtuoso - Keep state local β Don't lift state higher than necessary
- Use CSS for animations β Avoid JavaScript-driven animations
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Memoizing everything | Complexity overhead | Only memo expensive components |
| Missing dependency arrays | Stale closures or infinite loops | Always specify dependencies |
| Large component trees | Slow reconciliation | Split into smaller components |
| Inline objects/functions | Breaks memoization | Use useMemo/useCallback |
| Not profiling first | Optimizing the wrong thing | Use React DevTools Profiler |
Performance Optimization Checklist
| Category | Technique | Impact | Complexity |
|---|---|---|---|
| Rendering | React.memo | High | Low |
| Rendering | useMemo/useCallback | Medium | Low |
| Rendering | Virtualization | High | Medium |
| Bundle | Code splitting | High | Low |
| Bundle | Tree shaking | Medium | Low |
| Bundle | Dynamic imports | Medium | Low |
| State | Local state | Medium | Low |
| State | State colocation | Medium | Low |
| State | useReducer for complex state | Medium | Medium |
| Network | Data caching | High | Medium |
| Network | Prefetching | Medium | Low |
| Network | Lazy loading images | Medium | Low |
| CSS | CSS animations | Medium | Low |
| CSS | will-change property | Low | Low |
| Images | Responsive images | Medium | Low |
| Images | WebP/AVIF format | Medium | Low |
| Images | Lazy loading | Medium | Low |
React 18 Concurrent Features
React 18 introduced several performance improvements through concurrent features. Automatic batching groups multiple state updates into a single re-render, reducing the number of DOM updates. The startTransition API lets you mark updates as non-urgent, allowing React to keep the interface responsive during heavy computations. useDeferredValue lets you defer updating a part of the UI, showing stale content while new content loads in the background. Suspense boundaries enable streaming server-side rendering, sending HTML to the client as components become ready. These concurrent features work together to keep your application responsive even under heavy load, but they require understanding the priority model to use effectively.
import { useTransition, useDeferredValue, useState } from 'react';
function SearchPage({ items }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Urgent: update input immediately
const handleChange = (e) => {
setQuery(e.target.value);
// Non-urgent: defer the expensive filtering
startTransition(() => {
setFilteredResults(filterItems(items, e.target.value));
});
};
// Alternative: useDeferredValue defers the value itself
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<Results query={deferredQuery} items={items} />
</div>
</div>
);
}useTransition is ideal when you control the state update and want to mark it as low priority. useDeferredValue is better when the value comes from a prop or an external source you don't control. Both prevent the UI from blocking during expensive re-renders.
Streaming SSR with Suspense
React 18's streaming SSR sends HTML to the client progressively as components resolve:
// Server: stream components as they become ready
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
// Start streaming the shell
res.statusCode = 200;
pipe(res);
},
onError(err) {
console.error(err);
res.statusCode = 500;
},
});
});
// Client: Suspense boundaries show fallbacks while loading
function App() {
return (
<Layout>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<SlowContent />
</Suspense>
</Layout>
);
}The server sends the shell immediately, then streams the content of each Suspense boundary as it resolves. Users see meaningful content faster because the browser doesn't wait for the entire page to render on the server.
Production Monitoring
Monitor React performance in production using Real User Monitoring tools. Track component render counts, interaction latency, and bundle load times. Use the Web Vitals library to measure Core Web Vitals and correlate them with React-specific metrics. Set up alerts for performance regressions so your team can address issues before they affect users. Profile production builds using React DevTools to identify slow components in real-world scenarios.
// Track React-specific metrics in production
import { onCLS, onFID, onLCP } from 'web-vitals';
function reportMetric(metric) {
console.log(`${metric.name}: ${metric.value}`);
// Send to your analytics service
analytics.track('web-vital', {
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id
});
}
onCLS(reportMetric);
onFID(reportMetric);
onLCP(reportMetric);React Compiler: The Future of Performance
The React Compiler (formerly React Forget) represents the next evolution of React performance. Instead of manually applying useMemo, useCallback, and memo, the compiler automatically memoizes components and values at build time. This eliminates the most common source of performance bugsβforgetting to memoize or over-memoizing.
The compiler analyzes your component's data flow and determines the minimal set of re-renders needed. It understands when values are stable (derived from props or state that haven't changed) and automatically wraps them in memoization. This means developers can write simple, straightforward code and get optimal performance without manual optimization.
Combining memoization, code splitting, virtualization, server components, and compiler-driven optimization creates a comprehensive performance strategy that addresses rendering efficiency, bundle size optimization, and DOM manipulation overhead simultaneously. When these techniques are applied methodically based on profiling data, React applications can handle complex UIs with thousands of interactive elements while maintaining smooth, responsive user experiences.
Conclusion
React performance optimization is about understanding how React renders and applying the right technique for each bottleneck. Memoization prevents unnecessary re-renders, code splitting reduces bundle size, virtualization handles large lists, and proper state management keeps updates efficient. React 18's concurrent features add a new dimension by allowing you to prioritize updates, keeping the interface responsive even during heavy computations.
The most important takeaway: measure first. Use the React DevTools Profiler to identify actual bottlenecks before optimizing. Premature optimization adds complexity without guaranteed benefit. A component that re-renders unnecessarily but completes in 0.1ms is not worth memoizing β focus your effort on the components that actually impact user experience.
Key takeaways:
- Profile first β Use React DevTools to find actual bottlenecks
- React.memo β Prevents re-renders when props haven't changed
- useMemo/useCallback β Stabilize computations and references
- Code splitting β
lazy()+Suspensefor route-based splitting - Virtualization β
react-windowfor lists with thousands of items - Keep state local β Don't lift state higher than necessary
- CSS animations β Avoid JavaScript-driven animations
- Analyze bundles β Find and remove unnecessary dependencies
- Use React 18 concurrent features β startTransition and useDeferredValue for non-urgent updates
- Monitor production performance β Track render counts and interaction latency with RUM tools
- Leverage concurrent features β Use
useTransitionanduseDeferredValueto keep the UI responsive during heavy updates