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
- Understanding React Performance Bottlenecks
- Memoization Techniques
- React.memo: Preventing Unnecessary Re-renders
- useMemo: Memoizing Expensive Calculations
- useCallback: Memoizing Function References
- Code Splitting and Lazy Loading
- React.lazy and Suspense
- Route-Based Code Splitting
- Component-Level Code Splitting
- Virtualization and Windowing
- React Window: Efficient List Rendering
- React Virtual: Advanced Virtualization
- Performance Measurement and Profiling
- React DevTools Profiler
- Performance Monitoring in Production
- Common Performance Anti-Patterns
- Best Practices and Recommendations
- Conclusion
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 updateconst 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 changeconst 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 changesconst 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 renderconst 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 propsconst 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 memoizationconst Component = ({ a, b }) => { const sum = useMemo(() => a + b, [a, b]); // Overhead > benefit return <div>{sum}</div>;};
// ✅ Simple calculation doesn't need memoizationconst 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 componentconst 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 exportsconst Component = lazy(() => import("./Component"));
// ✅ For named exportsconst Component = lazy(() => import("./Component").then((module) => ({ default: module.NamedComponent, })),);
// ✅ Alternative approach for named exportsconst 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 componentsconst Home = lazy(() => import('./pages/Home'));const About = lazy(() => import('./pages/About'));const Blog = lazy(() => import('./pages/Blog'));const Contact = lazy(() => import('./pages/Contact'));
// Loading componentconst 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 UXconst 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 interactionconst 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> );};Modal and Dialog Code Splitting
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 nodesconst 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 + bufferconst 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
pnpm add react-windowpnpm add -D @types/react-windowFixed 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
pnpm add @tanstack/react-virtualBasic 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:
- Install React DevTools browser extension
- Open DevTools → Profiler tab
- Click “Record” and interact with your app
- Stop recording and analyze the flamegraph
- 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 timeconst 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, }); } } }; });};
// Usageconst ExpensiveComponent = () => { useRenderTime("ExpensiveComponent"); // Component logic};Web Vitals Monitoring
import { onCLS, onFID, onLCP } from "web-vitals";
// Measure Core Web Vitalsconst sendToAnalytics = (metric: any) => { // Send to your analytics service console.log(metric);};
onCLS(sendToAnalytics); // Cumulative Layout ShiftonFID(sendToAnalytics); // First Input DelayonLCP(sendToAnalytics); // Largest Contentful PaintCommon Performance Anti-Patterns
Understanding what NOT to do is just as important as knowing best practices.
❌ Over-Memoization
// ❌ Memoizing everything without profilingconst 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 loopconst Component = ({ items }: { items: Item[] }) => { const filtered = useMemo( () => items.filter((i) => i.active), [{ items }], // New object every render! );};
// ✅ Use primitive values or stable referencesconst 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 upconst Component = () => { useEffect(() => { const subscription = dataStream.subscribe((data) => { setData(data); }); // Missing cleanup! }, []);};
// ✅ Always clean up subscriptionsconst Component = () => { useEffect(() => { const subscription = dataStream.subscribe((data) => { setData(data); }); return () => subscription.unsubscribe(); // Cleanup }, []);};❌ Rendering Large Lists Without Virtualization
// ❌ Renders all 10,000 itemsconst LargeList = ({ items }: { items: Item[] }) => { return ( <div> {items.map(item => ( <ItemComponent key={item.id} item={item} /> ))} </div> );};
// ✅ Use virtualization for large listsconst 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:
- Code splitting - Reduces initial bundle size
- Virtualization - Handles large lists efficiently
- Memoization - Prevents unnecessary re-renders
- 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-windowfor simple cases,@tanstack/react-virtualfor 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:
- Profile your React application with React DevTools
- Identify the biggest performance bottlenecks
- Apply code splitting to reduce initial bundle size
- Implement virtualization for large lists
- Add memoization where profiling shows it’s needed
- Monitor performance metrics in production
Happy optimizing! 🚀