Skip to main content

React Performance Optimization: Memoization, Code Splitting, and Virtualization

Master React performance optimization with memoization, code splitting, and virtualization. Learn to use React.memo, useMemo, useCallback, lazy loading, and windowing techniques.

Table of Contents


Introduction

As React applications grow in complexity, performance optimization becomes crucial for delivering smooth user experiences. Slow rendering, unnecessary re-renders, and large bundle sizes can significantly impact your application’s performance, leading to poor user satisfaction and lower search engine rankings.

React provides powerful built-in optimization tools like React.memo, useMemo, and useCallback for memoization, along with React.lazy and Suspense for code splitting. Additionally, virtualization techniques help render large lists efficiently. Understanding when and how to use these optimization strategies is essential for building performant React applications.

This comprehensive guide will teach you how to optimize React applications using memoization, code splitting, and virtualization. You’ll learn practical techniques to reduce unnecessary re-renders, decrease bundle sizes, and efficiently render large datasets. By the end, you’ll be able to identify performance bottlenecks and apply the right optimization strategies for your specific use cases.


Understanding React Performance Bottlenecks

Before diving into optimization techniques, it’s important to understand what causes performance issues in React applications. Common bottlenecks include:

Unnecessary Re-renders: Components re-rendering when their props or state haven’t actually changed, often caused by creating new object or function references on each render.

Large Bundle Sizes: Loading entire application code upfront, even when users only need a small portion initially.

Rendering Large Lists: Rendering thousands of DOM nodes simultaneously, causing browser slowdowns.

Expensive Calculations: Performing heavy computations on every render instead of caching results.

Memory Leaks: Not cleaning up subscriptions, timers, or event listeners, leading to gradual performance degradation.

Understanding these bottlenecks helps you choose the right optimization strategy. For example, if you notice unnecessary re-renders, memoization is your solution. If initial load time is slow, code splitting will help. If rendering large lists causes jank, virtualization is the answer.


Memoization Techniques

Memoization is a technique that caches the results of expensive operations and returns the cached result when the same inputs occur again. React provides three main memoization tools: React.memo, useMemo, and useCallback. Each serves a specific purpose in preventing unnecessary work.

When to Use Memoization

Use memoization when:

  • Components receive props that don’t change frequently
  • Expensive calculations are performed on every render
  • Functions are passed as props to child components
  • Rendering large lists of components

Avoid memoization when:

  • Props change on every render anyway
  • The overhead of memoization exceeds the cost of re-rendering
  • Components are simple and render quickly
  • Premature optimization without profiling first

React.memo: Preventing Unnecessary Re-renders

React.memo is a higher-order component that memoizes the result of a component. It only re-renders when its props change, using shallow comparison by default.

Basic Usage

import React from 'react';
type UserCardProps = {
name: string;
email: string;
avatar: string;
};
// Without React.memo - re-renders on every parent update
const UserCard = ({ name, email, avatar }: UserCardProps) => {
console.log('UserCard rendered'); // Logs on every render
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
</div>
);
};
// With React.memo - only re-renders when props change
const MemoizedUserCard = React.memo(({ name, email, avatar }: UserCardProps) => {
console.log('MemoizedUserCard rendered'); // Only logs when props change
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<p>{email}</p>
</div>
);
});

Custom Comparison Function

For more control over when a component should re-render, you can provide a custom comparison function:

import React from 'react';
type ProductCardProps = {
id: number;
name: string;
price: number;
discount: number;
};
// Custom comparison: only re-render if price or discount changes
const ProductCard = React.memo<ProductCardProps>(
({ id, name, price, discount }) => {
const finalPrice = price - discount;
return (
<div className="product-card">
<h3>{name}</h3>
<p>Price: ${finalPrice}</p>
</div>
);
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
// Return false if props are different (re-render)
return (
prevProps.price === nextProps.price &&
prevProps.discount === nextProps.discount
);
}
);

Common Pitfall: Object and Function Props

⚠️ Important: React.memo uses shallow comparison, so passing new object or function references defeats its purpose:

// ❌ Anti-pattern: Creating new objects/functions on each render
const ParentComponent = () => {
const [count, setCount] = useState(0);
return (
<MemoizedUserCard
user={{ name: 'John', email: 'john@example.com' }} // New object every render!
onClick={() => console.log('clicked')} // New function every render!
/>
);
};
// ✅ Best practice: Memoize object and function props
const ParentComponent = () => {
const [count, setCount] = useState(0);
const user = useMemo(
() => ({ name: 'John', email: 'john@example.com' }),
[] // Only create once
);
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<MemoizedUserCard user={user} onClick={handleClick} />
);
};

useMemo: Memoizing Expensive Calculations

useMemo caches the result of an expensive computation and only recalculates when dependencies change. It’s perfect for expensive calculations, filtering large arrays, or transforming data.

Basic Usage

import { useMemo } from 'react';
type ProductListProps = {
products: Product[];
filter: string;
sortBy: 'name' | 'price';
};
const ProductList = ({ products, filter, sortBy }: ProductListProps) => {
// ❌ Expensive calculation runs on every render
const filteredAndSorted = products
.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
});
// ✅ Memoized: only recalculates when products, filter, or sortBy change
const filteredAndSorted = useMemo(() => {
return products
.filter(p => p.name.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
return a.price - b.price;
});
}, [products, filter, sortBy]);
return (
<ul>
{filteredAndSorted.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
};

Complex Calculation Example

import { useMemo } from 'react';
type DashboardProps = {
transactions: Transaction[];
dateRange: { start: Date; end: Date };
};
const Dashboard = ({ transactions, dateRange }: DashboardProps) => {
// Expensive analytics calculation
const analytics = useMemo(() => {
const filtered = transactions.filter(t =>
t.date >= dateRange.start && t.date <= dateRange.end
);
const total = filtered.reduce((sum, t) => sum + t.amount, 0);
const average = filtered.length > 0 ? total / filtered.length : 0;
const categories = filtered.reduce((acc, t) => {
acc[t.category] = (acc[t.category] || 0) + t.amount;
return acc;
}, {} as Record<string, number>);
const topCategory = Object.entries(categories).reduce(
(max, [cat, amount]) => amount > max[1] ? [cat, amount] : max,
['', 0]
);
return {
total,
average,
count: filtered.length,
topCategory: topCategory[0],
categoryBreakdown: categories,
};
}, [transactions, dateRange.start, dateRange.end]);
return (
<div>
<h2>Total: ${analytics.total}</h2>
<p>Average: ${analytics.average}</p>
<p>Top Category: {analytics.topCategory}</p>
</div>
);
};

When NOT to Use useMemo

Don’t use useMemo for:

  • Simple calculations (the overhead isn’t worth it)
  • Primitive values that are already cheap to compute
  • Every single calculation (profile first!)
// ❌ Unnecessary memoization
const Component = ({ a, b }) => {
const sum = useMemo(() => a + b, [a, b]); // Overhead > benefit
return <div>{sum}</div>;
};
// ✅ Simple calculation doesn't need memoization
const Component = ({ a, b }) => {
const sum = a + b; // Fast enough without memoization
return <div>{sum}</div>;
};

useCallback: Memoizing Function References

useCallback returns a memoized version of a callback function that only changes when dependencies change. It’s essential when passing functions as props to memoized components.

Basic Usage

import { useState, useCallback } from 'react';
import React from 'react';
type ButtonProps = {
onClick: () => void;
label: string;
};
const Button = React.memo(({ onClick, label }: ButtonProps) => {
console.log('Button rendered');
return <button onClick={onClick}>{label}</button>;
});
const ParentComponent = () => {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// ❌ New function reference on every render
const handleIncrement = () => {
setCount(c => c + 1);
};
// ✅ Memoized function reference
const handleIncrement = useCallback(() => {
setCount(c => c + 1);
}, []); // Empty deps = function never changes
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<Button onClick={handleIncrement} label="Increment" />
<p>Count: {count}</p>
</div>
);
};

useCallback with Dependencies

import { useState, useCallback } from 'react';
type SearchBarProps = {
onSearch: (query: string) => void;
minLength: number;
};
const SearchBar = React.memo(({ onSearch, minLength }: SearchBarProps) => {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.length >= minLength) {
onSearch(query);
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
});
const SearchPage = () => {
const [results, setResults] = useState([]);
const [minLength, setMinLength] = useState(3);
// ✅ Memoized with dependencies
const handleSearch = useCallback(async (query: string) => {
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
setResults(data);
}, []); // No deps needed - doesn't use any external values
// If handleSearch used minLength, we'd include it:
// const handleSearch = useCallback(async (query: string) => {
// if (query.length < minLength) return;
// const response = await fetch(`/api/search?q=${query}&min=${minLength}`);
// const data = await response.json();
// setResults(data);
// }, [minLength]); // Include minLength in deps
return (
<div>
<SearchBar onSearch={handleSearch} minLength={minLength} />
<ResultsList results={results} />
</div>
);
};

useCallback in Event Handlers

import { useCallback } from 'react';
const ProductList = ({ products, onProductClick }: ProductListProps) => {
// ✅ Memoize event handlers that are passed to many children
const handleClick = useCallback((productId: number) => {
onProductClick(productId);
}, [onProductClick]);
return (
<ul>
{products.map(product => (
<ProductItem
key={product.id}
product={product}
onClick={handleClick} // Stable reference
/>
))}
</ul>
);
};

💡 Tip: useCallback is most beneficial when the memoized function is passed to a memoized child component. If the child isn’t memoized, useCallback provides no benefit.


Code Splitting and Lazy Loading

Code splitting allows you to split your application into smaller chunks that are loaded on demand, reducing the initial bundle size and improving load times. This is especially important for large applications with many routes or features.

Benefits of Code Splitting

Reduced Initial Bundle Size: Users only download code they need immediately ✅ Faster Initial Load: Smaller bundles parse and execute faster ✅ Better Caching: Unchanged chunks can be cached separately ✅ Parallel Loading: Multiple chunks can load in parallel


React.lazy and Suspense

React.lazy enables code splitting at the component level by dynamically importing components. It works seamlessly with Suspense to handle loading states.

Basic Usage

import { lazy, Suspense } from 'react';
// ✅ Lazy load the component
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const App = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
};

Multiple Lazy Components

import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Profile = lazy(() => import('./Profile'));
const App = () => {
const [currentView, setCurrentView] = useState('dashboard');
const renderView = () => {
switch (currentView) {
case 'dashboard':
return <Dashboard />;
case 'settings':
return <Settings />;
case 'profile':
return <Profile />;
default:
return null;
}
};
return (
<div>
<nav>
<button onClick={() => setCurrentView('dashboard')}>Dashboard</button>
<button onClick={() => setCurrentView('settings')}>Settings</button>
<button onClick={() => setCurrentView('profile')}>Profile</button>
</nav>
<Suspense fallback={<div className="loading">Loading...</div>}>
{renderView()}
</Suspense>
</div>
);
};

Error Boundaries with Lazy Loading

import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
const AdminPanel = lazy(() => import('./AdminPanel'));
const App = () => {
return (
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<Suspense fallback={<div>Loading admin panel...</div>}>
<AdminPanel />
</Suspense>
</ErrorBoundary>
);
};

Named Exports with Lazy Loading

// ✅ For default exports
const Component = lazy(() => import("./Component"));
// ✅ For named exports
const Component = lazy(() =>
import("./Component").then((module) => ({
default: module.NamedComponent,
})),
);
// ✅ Alternative approach for named exports
const Component = lazy(() =>
import("./Component").then((module) => ({
default: module.Component,
})),
);

Route-Based Code Splitting

Route-based code splitting is the most common pattern, where each route is loaded as a separate chunk. This dramatically reduces initial bundle size for multi-page applications.

React Router Integration

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Blog = lazy(() => import('./pages/Blog'));
const Contact = lazy(() => import('./pages/Contact'));
// Loading component
const LoadingSpinner = () => (
<div className="flex items-center justify-center min-h-screen">
<div className="spinner">Loading...</div>
</div>
);
const App = () => {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/blog" element={<Blog />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</Suspense>
</BrowserRouter>
);
};

Per-Route Suspense Boundaries

import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// ✅ Individual Suspense boundaries for better UX
const App = () => {
return (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<Suspense fallback={<HomeSkeleton />}>
<Home />
</Suspense>
}
/>
<Route
path="/dashboard"
element={
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<SettingsSkeleton />}>
<Settings />
</Suspense>
}
/>
</Routes>
</BrowserRouter>
);
};

Preloading Routes

import { lazy, Suspense, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const Dashboard = lazy(() => import('./pages/Dashboard'));
// Preload component on hover or other interaction
const preloadDashboard = () => {
import('./pages/Dashboard');
};
const Navigation = () => {
const navigate = useNavigate();
return (
<nav>
<a
href="/dashboard"
onMouseEnter={preloadDashboard} // Preload on hover
onClick={(e) => {
e.preventDefault();
navigate('/dashboard');
}}
>
Dashboard
</a>
</nav>
);
};

Component-Level Code Splitting

Beyond route-based splitting, you can split at the component level for heavy features that aren’t always visible.

Conditional Component Loading

import { lazy, Suspense, useState } from 'react';
const Chart = lazy(() => import('./Chart'));
const DataTable = lazy(() => import('./DataTable'));
const AnalyticsDashboard = () => {
const [showChart, setShowChart] = useState(false);
const [showTable, setShowTable] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>Show Chart</button>
<button onClick={() => setShowTable(true)}>Show Table</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
)}
{showTable && (
<Suspense fallback={<div>Loading table...</div>}>
<DataTable />
</Suspense>
)}
</div>
);
};
import { lazy, Suspense, useState } from 'react';
const UserModal = lazy(() => import('./UserModal'));
const ConfirmDialog = lazy(() => import('./ConfirmDialog'));
const UserList = () => {
const [selectedUser, setSelectedUser] = useState(null);
const [showConfirm, setShowConfirm] = useState(false);
return (
<>
<ul>
{users.map(user => (
<li key={user.id}>
<button onClick={() => setSelectedUser(user)}>
View {user.name}
</button>
</li>
))}
</ul>
{selectedUser && (
<Suspense fallback={<div>Loading...</div>}>
<UserModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
/>
</Suspense>
)}
{showConfirm && (
<Suspense fallback={null}>
<ConfirmDialog
onConfirm={() => {/* ... */}}
onCancel={() => setShowConfirm(false)}
/>
</Suspense>
)}
</>
);
};

Virtualization and Windowing

Virtualization (also called windowing) is a technique that renders only the visible items in a list, plus a small buffer. This allows you to efficiently render lists with thousands or millions of items without performance degradation.

Why Virtualization?

When rendering large lists, creating DOM nodes for every item becomes expensive:

// ❌ Rendering 10,000 items creates 10,000 DOM nodes
const LargeList = ({ items }) => {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li> // 10,000 DOM nodes!
))}
</ul>
);
};

Virtualization solves this by only rendering visible items:

// ✅ Only renders ~20 visible items + buffer
const VirtualizedList = ({ items }) => {
// Only renders items in viewport
return (
<VirtualList items={items} height={600} itemHeight={50}>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</VirtualList>
);
};

React Window: Efficient List Rendering

react-window is a popular, lightweight library for virtualizing lists. It provides components for fixed-size lists, variable-size lists, grids, and more.

Installation

Terminal window
pnpm add react-window
pnpm add -D @types/react-window

Fixed Size List

import { FixedSizeList } from 'react-window';
type Item = {
id: number;
name: string;
email: string;
};
type UserListProps = {
users: Item[];
};
const UserList = ({ users }: UserListProps) => {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className="user-row">
<div>{users[index].name}</div>
<div>{users[index].email}</div>
</div>
);
return (
<FixedSizeList
height={600} // Total height of the list
itemCount={users.length} // Total number of items
itemSize={50} // Height of each item
width="100%"
>
{Row}
</FixedSizeList>
);
};

Variable Size List

import { VariableSizeList } from 'react-window';
type Message = {
id: number;
text: string;
author: string;
};
const MessageList = ({ messages }: { messages: Message[] }) => {
// Calculate item sizes dynamically
const getItemSize = (index: number) => {
const message = messages[index];
// Estimate height based on text length
const lines = Math.ceil(message.text.length / 80);
return Math.max(60, lines * 20 + 40);
};
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style} className="message-item">
<div className="author">{messages[index].author}</div>
<div className="text">{messages[index].text}</div>
</div>
);
return (
<VariableSizeList
height={600}
itemCount={messages.length}
itemSize={getItemSize}
width="100%"
>
{Row}
</VariableSizeList>
);
};

Grid Virtualization

import { FixedSizeGrid } from 'react-window';
type ProductGridProps = {
products: Product[];
columns?: number;
};
const ProductGrid = ({ products, columns = 4 }: ProductGridProps) => {
const Cell = ({
columnIndex,
rowIndex,
style,
}: {
columnIndex: number;
rowIndex: number;
style: React.CSSProperties;
}) => {
const index = rowIndex * columns + columnIndex;
const product = products[index];
if (!product) return null;
return (
<div style={style} className="product-cell">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
};
const rowCount = Math.ceil(products.length / columns);
return (
<FixedSizeGrid
columnCount={columns}
columnWidth={250}
height={600}
rowCount={rowCount}
rowHeight={300}
width={1000}
>
{Cell}
</FixedSizeGrid>
);
};

Dynamic Item Heights with useMemo

import { VariableSizeList } from 'react-window';
import { useMemo, useRef } from 'react';
const DynamicList = ({ items }: { items: Item[] }) => {
const listRef = useRef<VariableSizeList>(null);
const sizeMap = useRef<Map<number, number>>(new Map());
const setSize = (index: number, size: number) => {
if (sizeMap.current.get(index) !== size) {
sizeMap.current.set(index, size);
listRef.current?.resetAfterIndex(index);
}
};
const getSize = (index: number) => {
return sizeMap.current.get(index) || 50;
};
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rowRef.current) {
setSize(index, rowRef.current.getBoundingClientRect().height);
}
}, [index]);
return (
<div ref={rowRef} style={style}>
{items[index].content}
</div>
);
};
return (
<VariableSizeList
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getSize}
width="100%"
>
{Row}
</VariableSizeList>
);
};

React Virtual: Advanced Virtualization

@tanstack/react-virtual (formerly react-virtual) is a more modern alternative with additional features like smooth scrolling, dynamic sizing, and better TypeScript support.

Installation

Terminal window
pnpm add @tanstack/react-virtual

Basic Usage

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
const VirtualList = ({ items }: { items: Item[] }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5, // Render 5 extra items outside viewport
});
return (
<div
ref={parentRef}
style={{ height: '600px', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
);
};

Horizontal Virtualization

import { useVirtualizer } from '@tanstack/react-virtual';
const HorizontalList = ({ items }: { items: Item[] }) => {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 200,
horizontal: true, // Enable horizontal scrolling
});
return (
<div
ref={parentRef}
style={{ width: '100%', overflowX: 'auto' }}
>
<div
style={{
width: `${virtualizer.getTotalSize()}px`,
height: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualItem => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualItem.size}px`,
height: '100%',
transform: `translateX(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index].content}
</div>
))}
</div>
</div>
);
};

Performance Measurement and Profiling

Before optimizing, you need to identify bottlenecks. React provides excellent tools for measuring and profiling performance.

React DevTools Profiler

The React DevTools Profiler helps you identify components that re-render frequently or take a long time to render.

How to use:

  1. Install React DevTools browser extension
  2. Open DevTools → Profiler tab
  3. Click “Record” and interact with your app
  4. Stop recording and analyze the flamegraph
  5. Look for components with long render times or frequent re-renders

💡 Tip: Focus on optimizing components that:

  • Render frequently (yellow/orange in profiler)
  • Take a long time to render (tall bars)
  • Are in the critical rendering path

Performance Monitoring in Production

import { useEffect } from "react";
// Measure component render time
const useRenderTime = (componentName: string) => {
useEffect(() => {
const start = performance.now();
return () => {
const end = performance.now();
const renderTime = end - start;
if (renderTime > 16) {
// Longer than one frame (16ms)
console.warn(`${componentName} took ${renderTime}ms to render`);
// Send to analytics
if (window.analytics) {
window.analytics.track("slow_render", {
component: componentName,
duration: renderTime,
});
}
}
};
});
};
// Usage
const ExpensiveComponent = () => {
useRenderTime("ExpensiveComponent");
// Component logic
};

Web Vitals Monitoring

import { onCLS, onFID, onLCP } from "web-vitals";
// Measure Core Web Vitals
const sendToAnalytics = (metric: any) => {
// Send to your analytics service
console.log(metric);
};
onCLS(sendToAnalytics); // Cumulative Layout Shift
onFID(sendToAnalytics); // First Input Delay
onLCP(sendToAnalytics); // Largest Contentful Paint

Common Performance Anti-Patterns

Understanding what NOT to do is just as important as knowing best practices.

❌ Over-Memoization

// ❌ Memoizing everything without profiling
const Component = ({ name }: { name: string }) => {
const memoizedName = useMemo(() => name, [name]); // Unnecessary!
const memoizedRender = useCallback(() => <div>{name}</div>, [name]); // Unnecessary!
return <MemoizedChild name={memoizedName} render={memoizedRender} />;
};

❌ Creating Objects in Dependency Arrays

// ❌ Object created on every render = infinite loop
const Component = ({ items }: { items: Item[] }) => {
const filtered = useMemo(
() => items.filter((i) => i.active),
[{ items }], // New object every render!
);
};
// ✅ Use primitive values or stable references
const Component = ({ items }: { items: Item[] }) => {
const filtered = useMemo(
() => items.filter((i) => i.active),
[items], // Stable array reference
);
};

❌ Not Cleaning Up Effects

// ❌ Memory leak: subscription never cleaned up
const Component = () => {
useEffect(() => {
const subscription = dataStream.subscribe((data) => {
setData(data);
});
// Missing cleanup!
}, []);
};
// ✅ Always clean up subscriptions
const Component = () => {
useEffect(() => {
const subscription = dataStream.subscribe((data) => {
setData(data);
});
return () => subscription.unsubscribe(); // Cleanup
}, []);
};

❌ Rendering Large Lists Without Virtualization

// ❌ Renders all 10,000 items
const LargeList = ({ items }: { items: Item[] }) => {
return (
<div>
{items.map(item => (
<ItemComponent key={item.id} item={item} />
))}
</div>
);
};
// ✅ Use virtualization for large lists
const LargeList = ({ items }: { items: Item[] }) => {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
>
{({ index, style }) => (
<div style={style}>
<ItemComponent item={items[index]} />
</div>
)}
</FixedSizeList>
);
};

Best Practices and Recommendations

✅ Profile Before Optimizing

Always measure performance before and after optimizations. Use React DevTools Profiler to identify actual bottlenecks rather than guessing.

✅ Start with the Biggest Impact

Optimize in this order for maximum impact:

  1. Code splitting - Reduces initial bundle size
  2. Virtualization - Handles large lists efficiently
  3. Memoization - Prevents unnecessary re-renders
  4. Fine-tuning - Optimize specific expensive operations

✅ Use Memoization Strategically

  • Memoize components that receive stable props
  • Memoize expensive calculations with useMemo
  • Memoize callbacks passed to memoized children with useCallback
  • Don’t memoize everything - profile first!

✅ Implement Code Splitting Early

  • Split routes by default in large applications
  • Lazy load heavy features and modals
  • Preload critical routes on user interaction
  • Use Suspense boundaries for better UX

✅ Virtualize Large Lists

  • Use virtualization for lists with 100+ items
  • Consider virtualization for lists with 50+ items if items are complex
  • Use react-window for simple cases, @tanstack/react-virtual for advanced needs

✅ Monitor Performance in Production

  • Track Core Web Vitals
  • Monitor component render times
  • Set up alerts for performance regressions
  • Use React DevTools Profiler in production builds

✅ Keep Dependencies Updated

  • Update React and optimization libraries regularly
  • React 18+ includes automatic batching and concurrent features
  • Newer versions often include performance improvements

Conclusion

React performance optimization is a crucial skill for building fast, responsive applications. By strategically applying memoization, code splitting, and virtualization, you can dramatically improve your application’s performance and user experience.

Key Takeaways:

  • Memoization (React.memo, useMemo, useCallback) prevents unnecessary re-renders and expensive recalculations
  • Code splitting (React.lazy, Suspense) reduces initial bundle size and improves load times
  • Virtualization (react-window, @tanstack/react-virtual) enables efficient rendering of large lists
  • Profiling is essential - always measure before and after optimizations
  • Strategic optimization - focus on the biggest impact areas first

Remember: premature optimization can add complexity without benefits. Always profile your application first to identify real bottlenecks, then apply the appropriate optimization techniques. For more React best practices, check out our guide on common React pitfalls and state management strategies.

For broader performance optimization strategies beyond React, explore our web performance optimization guide covering Core Web Vitals and other optimization techniques.

Next Steps:

  1. Profile your React application with React DevTools
  2. Identify the biggest performance bottlenecks
  3. Apply code splitting to reduce initial bundle size
  4. Implement virtualization for large lists
  5. Add memoization where profiling shows it’s needed
  6. Monitor performance metrics in production

Happy optimizing! 🚀