Performance Optimization — React.memo, Code Splitting, Profiler
React Performance Optimization Basics
By default, React re-renders all child components whenever a parent re-renders. This is fast enough in most cases, but optimization is needed for heavy components.
When Re-renders Occur
- When a component's state changes
- When a component's props change
- When a parent component re-renders
- When a subscribed Context value changes
React.memo
Skips re-rendering when props haven't changed. Uses shallow comparison.
import { memo, useState } from 'react';
// ✅ Component wrapped with memo — no re-render when props are the same
const ExpensiveChart = memo(function ExpensiveChart({ data, title }) {
console.log('ExpensiveChart rendering');
// Expensive chart rendering logic...
return (
<div>
<h2>{title}</h2>
<canvas>{/* Chart */}</canvas>
</div>
);
});
function Dashboard() {
const [count, setCount] = useState(0);
const chartData = [10, 20, 30, 40]; // ❌ New array on every render
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
{/* ExpensiveChart won't re-render even when count changes */}
{/* But chartData is a new array so it actually re-renders → useMemo needed */}
<ExpensiveChart data={chartData} title="Sales Overview" />
</div>
);
}
memo + useMemo + useCallback Combination
import { memo, useState, useMemo, useCallback } from 'react';
const ProductCard = memo(function ProductCard({ product, onAddToCart }) {
console.log(`ProductCard ${product.id} rendering`);
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
</div>
);
});
function ProductList({ allProducts, category }) {
const [cartItems, setCartItems] = useState([]);
// useMemo: cache filtering result
const filteredProducts = useMemo(
() => allProducts.filter(p => p.category === category),
[allProducts, category]
);
// useCallback: stabilize function reference
const handleAddToCart = useCallback((productId) => {
setCartItems(prev => [...prev, productId]);
}, []);
return (
<ul>
{filteredProducts.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart} // Always same reference
/>
))}
</ul>
);
}
Custom Comparison Function
const Article = memo(
function Article({ article }) {
return <div>{article.title}</div>;
},
// Second argument: comparison function for previous/next props
// Return true → skip rendering
(prevProps, nextProps) =>
prevProps.article.id === nextProps.article.id &&
prevProps.article.updatedAt === nextProps.article.updatedAt
);
Code Splitting
Splits the bundle into multiple chunks to improve initial loading speed.
React.lazy + Suspense
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Dynamic import — only loads when the route is accessed
const HomePage = lazy(() => import('./pages/HomePage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));
const HeavyChartLibrary = lazy(() => import('./components/HeavyChart'));
function App() {
return (
<Suspense fallback={<div className="page-loader">Loading...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductPage />} />
<Route path="/admin" element={<AdminPage />} />
</Routes>
</Suspense>
);
}
Suspense Boundary Design
// ✅ Place multiple Suspense boundaries at appropriate positions
function App() {
return (
<div>
<Header /> {/* Always displayed quickly */}
<Suspense fallback={<MainContentSkeleton />}>
<MainContent /> {/* Show skeleton while main content loads */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* Sidebar loads separately */}
</Suspense>
</div>
);
}
Component-level Lazy Loading
function ProductDetail({ id }) {
const [showReviews, setShowReviews] = useState(false);
// Reviews section only loads when needed
const ReviewSection = lazy(() => import('./ReviewSection'));
return (
<div>
<ProductInfo id={id} />
<button onClick={() => setShowReviews(true)}>Show Reviews</button>
{showReviews && (
<Suspense fallback={<p>Loading reviews...</p>}>
<ReviewSection productId={id} />
</Suspense>
)}
</div>
);
}
Performance Measurement with React Profiler
Profiler API
import { Profiler } from 'react';
function onRenderCallback(
id, // "id" prop
phase, // "mount" | "update" | "nested-update"
actualDuration, // Time taken to render (ms)
baseDuration, // Estimated time without optimization (ms)
startTime, // Render start timestamp
commitTime // Commit timestamp
) {
// Performance logging
if (actualDuration > 16) { // Exceeds 16ms for 60fps
console.warn(`${id} slow rendering: ${actualDuration.toFixed(2)}ms`);
}
}
function App() {
return (
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList />
</Profiler>
);
}
React DevTools Profiler
In the browser DevTools Profiler tab:
- Click "Record" then perform interactions
- Click "Stop"
- Check rendering time for each component
- Use "Why did this render?" to identify re-render causes
Patterns for Minimizing Re-renders
State Positioning Optimization
// ❌ State placed unnecessarily high up
function App() {
const [inputValue, setInputValue] = useState(''); // Entire App re-renders
return (
<div>
<Header /> {/* Unnecessary re-render */}
<Sidebar /> {/* Unnecessary re-render */}
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<SearchResults query={inputValue} />
</div>
);
}
// ✅ Move state down to the component that uses it (State Colocation)
function SearchSection() {
const [inputValue, setInputValue] = useState('');
return (
<div>
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<SearchResults query={inputValue} />
</div>
);
}
function App() {
return (
<div>
<Header /> {/* No re-render */}
<Sidebar /> {/* No re-render */}
<SearchSection />
</div>
);
}
Content Lifting Pattern
// ✅ Use children to isolate re-renders
function SlowParent({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
{children} {/* children don't re-render when count changes */}
</div>
);
}
function App() {
return (
<SlowParent>
<ExpensiveComponent /> {/* Unaffected by SlowParent re-renders */}
</SlowParent>
);
}
Virtualization
When rendering thousands of items, only actually renders items visible on screen.
npm install react-window
import { FixedSizeList, VariableSizeList } from 'react-window';
const ITEM_HEIGHT = 60;
function Row({ index, style, data }) {
const item = data[index];
return (
<div style={style} className="list-item">
<strong>{item.name}</strong>
<span>{item.email}</span>
</div>
);
}
function VirtualizedUserList({ users }) {
return (
<FixedSizeList
height={600} // Container height (px)
itemCount={users.length}
itemSize={ITEM_HEIGHT} // Each item height
itemData={users}
width="100%"
>
{Row}
</FixedSizeList>
);
}
// Variable height items
function VariableList({ items }) {
const getItemSize = (index) => items[index].expanded ? 120 : 60;
return (
<VariableSizeList
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
>
{({ index, style }) => (
<div style={style}>{items[index].title}</div>
)}
</VariableSizeList>
);
}
Practical Example: Optimized Infinite Scroll List
import { memo, useCallback } from 'react';
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { useInfiniteQuery } from '@tanstack/react-query';
const PAGE_SIZE = 20;
const ITEM_HEIGHT = 72;
// Optimize item component with memo
const ListItem = memo(function ListItem({ style, user }) {
if (!user) return <div style={style} className="skeleton" />;
return (
<div style={style} className="user-row">
<img src={user.avatar} alt="" width={40} height={40} />
<div>
<strong>{user.name}</strong>
<p>{user.email}</p>
</div>
</div>
);
});
function OptimizedUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/users?offset=${pageParam}&limit=${PAGE_SIZE}`).then(r => r.json()),
getNextPageParam: (last, all) =>
last.length === PAGE_SIZE ? all.flat().length : undefined,
initialPageParam: 0,
});
const users = data?.pages.flat() ?? [];
const itemCount = hasNextPage ? users.length + 1 : users.length;
const isItemLoaded = useCallback(
(index) => !hasNextPage || index < users.length,
[hasNextPage, users.length]
);
const loadMoreItems = useCallback(
() => !isFetchingNextPage ? fetchNextPage() : Promise.resolve(),
[isFetchingNextPage, fetchNextPage]
);
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref }) => (
<FixedSizeList
ref={ref}
height={600}
itemCount={itemCount}
itemSize={ITEM_HEIGHT}
onItemsRendered={onItemsRendered}
width="100%"
>
{({ index, style }) => (
<ListItem style={style} user={users[index]} />
)}
</FixedSizeList>
)}
</InfiniteLoader>
);
}
Pro Tips
1. Measure Before Optimizing
Optimizing without measurement only makes code more complex. Use the React DevTools Profiler or console.time to identify bottlenecks first.
2. Don't Overuse React.memo
Memoization itself has a cost (comparison operations). Only use it when:
- Component props don't change frequently
- Component rendering is actually slow
3. Wait for the React 19 Compiler
Once the React 19 Compiler stabilizes, the need to manually write memo, useMemo, and useCallback will be greatly reduced.