Skip to main content

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

  1. When a component's state changes
  2. When a component's props change
  3. When a parent component re-renders
  4. 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:

  1. Click "Record" then perform interactions
  2. Click "Stop"
  3. Check rendering time for each component
  4. 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.