Performance
React is fast by default — its virtual DOM diffing algorithm is highly optimized. But as applications grow, unnecessary re-renders, large bundle sizes, and expensive computations can degrade performance. This guide covers every optimization technique, when to use them, and — importantly — when not to.
The golden rule of performance optimization: measure first, optimize second. Use the React DevTools Profiler to identify actual bottlenecks before applying optimizations. Most React apps don't need manual optimization — the default behavior is good enough.
Understanding re-renders is the foundation of React performance. A component re-renders when:
| 1 | // Trigger 1: State changes |
| 2 | function Counter() { |
| 3 | const [count, setCount] = useState(0); |
| 4 | // Re-renders when count changes via setCount |
| 5 | return <div>{count}</div>; |
| 6 | } |
| 7 | |
| 8 | // Trigger 2: Props change (from parent re-render) |
| 9 | function Parent() { |
| 10 | const [count, setCount] = useState(0); |
| 11 | // Child re-renders even though its props didn't change! |
| 12 | return ( |
| 13 | <div> |
| 14 | <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> |
| 15 | <Child /> {/* Re-renders because Parent re-rendered */} |
| 16 | </div> |
| 17 | ); |
| 18 | } |
| 19 | |
| 20 | // Trigger 3: Parent re-renders (even if props are the same) |
| 21 | // This is the #1 cause of unnecessary re-renders |
| 22 | |
| 23 | // Trigger 4: Context value changes |
| 24 | function ThemedButton() { |
| 25 | const { theme } = useContext(ThemeContext); |
| 26 | // Re-renders when theme context value changes |
| 27 | } |
| 28 | |
| 29 | // The key insight: React re-renders children by default |
| 30 | // when the parent re-renders, regardless of whether |
| 31 | // the child's props actually changed |
note
React.memowraps a component to skip re-rendering when its props haven't changed. It performs a shallow comparison of props. Only use it when a component re-renders frequently with the same props.
| 1 | import { memo, useState } from "react"; |
| 2 | |
| 3 | // Without memo — re-renders every time parent re-renders |
| 4 | function ExpensiveList({ items }) { |
| 5 | console.log("ExpensiveList rendered"); |
| 6 | return ( |
| 7 | <ul> |
| 8 | {items.map(item => ( |
| 9 | <li key={item.id}>{item.name} — {item.description}</li> |
| 10 | ))} |
| 11 | </ul> |
| 12 | ); |
| 13 | } |
| 14 | |
| 15 | // With memo — only re-renders when items prop changes |
| 16 | const MemoizedList = memo(function ExpensiveList({ items }) { |
| 17 | console.log("ExpensiveList rendered"); |
| 18 | return ( |
| 19 | <ul> |
| 20 | {items.map(item => ( |
| 21 | <li key={item.id}>{item.name} — {item.description}</li> |
| 22 | ))} |
| 23 | </ul> |
| 24 | ); |
| 25 | }); |
| 26 | |
| 27 | // Custom comparison function |
| 28 | const OptimizedList = memo( |
| 29 | function ExpensiveList({ items, renderItem }) { |
| 30 | return ( |
| 31 | <ul> |
| 32 | {items.map(item => ( |
| 33 | <li key={item.id}>{renderItem(item)}</li> |
| 34 | ))} |
| 35 | </ul> |
| 36 | ); |
| 37 | }, |
| 38 | (prevProps, nextProps) => { |
| 39 | // Return true if props are equal (skip re-render) |
| 40 | return ( |
| 41 | prevProps.items === nextProps.items && |
| 42 | prevProps.renderItem === nextProps.renderItem |
| 43 | ); |
| 44 | } |
| 45 | ); |
| 46 | |
| 47 | // Parent component |
| 48 | function Dashboard() { |
| 49 | const [count, setCount] = useState(0); |
| 50 | const items = useMemo(() => fetchItems(), []); |
| 51 | |
| 52 | return ( |
| 53 | <div> |
| 54 | <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> |
| 55 | <ExpensiveList items={items} /> {/* re-renders on every count change */} |
| 56 | <MemoizedList items={items} /> {/* only re-renders when items change */} |
| 57 | <OptimizedList items={items} /> {/* custom comparison */} |
| 58 | </div> |
| 59 | ); |
| 60 | } |
warning
useMemo caches expensive computations. useCallback caches function references. Both prevent unnecessary re-renders of memoized children and expensive operations.
| 1 | import { useMemo, useCallback, memo } from "react"; |
| 2 | |
| 3 | // Expensive computation — memoize with useMemo |
| 4 | function DataGrid({ data, sortConfig, filter }) { |
| 5 | const processedData = useMemo(() => { |
| 6 | console.log("Processing data..."); |
| 7 | return data |
| 8 | .filter(item => { |
| 9 | if (filter.category && item.category !== filter.category) return false; |
| 10 | if (filter.minPrice && item.price < filter.minPrice) return false; |
| 11 | if (filter.search && !item.name.toLowerCase().includes(filter.search.toLowerCase())) return false; |
| 12 | return true; |
| 13 | }) |
| 14 | .sort((a, b) => { |
| 15 | if (!sortConfig.key) return 0; |
| 16 | const aVal = a[sortConfig.key]; |
| 17 | const bVal = b[sortConfig.key]; |
| 18 | return sortConfig.direction === "asc" |
| 19 | ? aVal.localeCompare(bVal) |
| 20 | : bVal.localeCompare(aVal); |
| 21 | }); |
| 22 | }, [data, sortConfig, filter]); // only recompute when these change |
| 23 | |
| 24 | return ( |
| 25 | <table> |
| 26 | <tbody> |
| 27 | {processedData.map(item => ( |
| 28 | <TableRow key={item.id} item={item} /> |
| 29 | ))} |
| 30 | </tbody> |
| 31 | </table> |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | // Stable callback — prevents child re-renders |
| 36 | function TodoList({ todos, onToggle, onDelete }) { |
| 37 | const handleToggle = useCallback((id) => { |
| 38 | onToggle(id); |
| 39 | }, [onToggle]); |
| 40 | |
| 41 | const handleDelete = useCallback((id) => { |
| 42 | onDelete(id); |
| 43 | }, [onDelete]); |
| 44 | |
| 45 | return ( |
| 46 | <ul> |
| 47 | {todos.map(todo => ( |
| 48 | <TodoItem |
| 49 | key={todo.id} |
| 50 | todo={todo} |
| 51 | onToggle={handleToggle} |
| 52 | onDelete={handleDelete} |
| 53 | /> |
| 54 | ))} |
| 55 | </ul> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) { |
| 60 | return ( |
| 61 | <li> |
| 62 | <span onClick={() => onToggle(todo.id)}> |
| 63 | {todo.done ? "✓" : "○"} {todo.text} |
| 64 | </span> |
| 65 | <button onClick={() => onDelete(todo.id)}>×</button> |
| 66 | </li> |
| 67 | ); |
| 68 | }); |
best practice
Code splitting breaks your bundle into smaller chunks loaded on demand. React.lazy and Suspense enable component-level code splitting — users only download the code for the routes they visit.
| 1 | import { lazy, Suspense } from "react"; |
| 2 | |
| 3 | // Lazy-load route components |
| 4 | const Dashboard = lazy(() => import("./pages/Dashboard")); |
| 5 | const Settings = lazy(() => import("./pages/Settings")); |
| 6 | const Analytics = lazy(() => import("./pages/Analytics")); |
| 7 | const AdminPanel = lazy(() => import("./pages/AdminPanel")); |
| 8 | |
| 9 | function App() { |
| 10 | return ( |
| 11 | <BrowserRouter> |
| 12 | <Routes> |
| 13 | <Route path="/" element={<HomePage />} /> |
| 14 | <Route |
| 15 | path="/dashboard" |
| 16 | element={ |
| 17 | <Suspense fallback={<PageSkeleton />}> |
| 18 | <Dashboard /> |
| 19 | </Suspense> |
| 20 | } |
| 21 | /> |
| 22 | <Route |
| 23 | path="/settings" |
| 24 | element={ |
| 25 | <Suspense fallback={<PageSkeleton />}> |
| 26 | <Settings /> |
| 27 | </Suspense> |
| 28 | } |
| 29 | /> |
| 30 | <Route |
| 31 | path="/analytics" |
| 32 | element={ |
| 33 | <Suspense fallback={<PageSkeleton />}> |
| 34 | <Analytics /> |
| 35 | </Suspense> |
| 36 | } |
| 37 | /> |
| 38 | <Route |
| 39 | path="/admin" |
| 40 | element={ |
| 41 | <Suspense fallback={<PageSkeleton />}> |
| 42 | <AdminPanel /> |
| 43 | </Suspense> |
| 44 | } |
| 45 | /> |
| 46 | </Routes> |
| 47 | </BrowserRouter> |
| 48 | ); |
| 49 | } |
| 50 | |
| 51 | // Nested Suspense with fallback hierarchy |
| 52 | function App() { |
| 53 | return ( |
| 54 | <Suspense fallback={<AppSkeleton />}> |
| 55 | <Routes> |
| 56 | <Route |
| 57 | path="/dashboard" |
| 58 | element={ |
| 59 | <Suspense fallback={<DashboardSkeleton />}> |
| 60 | <Dashboard /> |
| 61 | </Suspense> |
| 62 | } |
| 63 | /> |
| 64 | </Routes> |
| 65 | </Suspense> |
| 66 | ); |
| 67 | } |
| 68 | |
| 69 | // Lazy-load heavy components (charts, editors, etc.) |
| 70 | function ReportPage() { |
| 71 | const [showChart, setShowChart] = useState(false); |
| 72 | |
| 73 | return ( |
| 74 | <div> |
| 75 | <button onClick={() => setShowChart(true)}>Show Chart</button> |
| 76 | {showChart && ( |
| 77 | <Suspense fallback={<div>Loading chart...</div>}> |
| 78 | <HeavyChart /> {/* Only loaded when needed */} |
| 79 | </Suspense> |
| 80 | )} |
| 81 | </div> |
| 82 | ); |
| 83 | } |
| 84 | |
| 85 | const HeavyChart = lazy(() => import("./components/HeavyChart")); |
pro tip
Virtualization renders only the visible items in a list, dramatically reducing DOM nodes and improving performance for large datasets. Libraries like @tanstack/react-virtual handle this.
| 1 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 2 | import { useRef } from "react"; |
| 3 | |
| 4 | function VirtualList({ items, estimateSize = 50 }) { |
| 5 | const parentRef = useRef(null); |
| 6 | |
| 7 | const virtualizer = useVirtualizer({ |
| 8 | count: items.length, |
| 9 | getScrollElement: () => parentRef.current, |
| 10 | estimateSize: () => estimateSize, |
| 11 | overscan: 5, // number of items to render outside viewport |
| 12 | }); |
| 13 | |
| 14 | return ( |
| 15 | <div |
| 16 | ref={parentRef} |
| 17 | className="h-[500px] overflow-auto" // must have fixed height |
| 18 | > |
| 19 | <div |
| 20 | style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }} |
| 21 | > |
| 22 | {virtualizer.getVirtualItems().map(virtualItem => ( |
| 23 | <div |
| 24 | key={virtualItem.index} |
| 25 | style={{ |
| 26 | position: "absolute", |
| 27 | top: 0, |
| 28 | left: 0, |
| 29 | width: "100%", |
| 30 | height: `${virtualItem.size}px`, |
| 31 | transform: `translateY(${virtualItem.start}px)`, |
| 32 | }} |
| 33 | > |
| 34 | {items[virtualItem.index].name} |
| 35 | </div> |
| 36 | ))} |
| 37 | </div> |
| 38 | </div> |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | // Grid virtualization |
| 43 | function VirtualGrid({ items, columns = 4 }) { |
| 44 | const parentRef = useRef(null); |
| 45 | const rowCount = Math.ceil(items.length / columns); |
| 46 | |
| 47 | const rowVirtualizer = useVirtualizer({ |
| 48 | count: rowCount, |
| 49 | getScrollElement: () => parentRef.current, |
| 50 | estimateSize: () => 200, |
| 51 | }); |
| 52 | |
| 53 | return ( |
| 54 | <div ref={parentRef} className="h-[600px] overflow-auto"> |
| 55 | <div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}> |
| 56 | {rowVirtualizer.getVirtualItems().map(virtualRow => ( |
| 57 | <div |
| 58 | key={virtualRow.index} |
| 59 | style={{ |
| 60 | position: "absolute", |
| 61 | top: 0, |
| 62 | left: 0, |
| 63 | width: "100%", |
| 64 | transform: `translateY(${virtualRow.start}px)`, |
| 65 | }} |
| 66 | className="grid grid-cols-4 gap-4" |
| 67 | > |
| 68 | {Array.from({ length: columns }).map((_, colIdx) => { |
| 69 | const itemIndex = virtualRow.index * columns + colIdx; |
| 70 | const item = items[itemIndex]; |
| 71 | return item ? <Card key={item.id} item={item} /> : null; |
| 72 | })} |
| 73 | </div> |
| 74 | ))} |
| 75 | </div> |
| 76 | </div> |
| 77 | ); |
| 78 | } |
info
Reducing bundle size means faster page loads. Analyze your bundle, remove unused code, and use tree-shaking-friendly libraries.
| 1 | # Analyze bundle size with Vite |
| 2 | npm install -D rollup-plugin-visualizer |
| 3 | |
| 4 | // vite.config.ts |
| 5 | import { visualizer } from "rollup-plugin-visualizer"; |
| 6 | |
| 7 | export default defineConfig({ |
| 8 | plugins: [ |
| 9 | react(), |
| 10 | visualizer({ |
| 11 | open: true, |
| 12 | gzipSize: true, |
| 13 | filename: "bundle-analysis.html", |
| 14 | }), |
| 15 | ], |
| 16 | }); |
| 17 | |
| 18 | # Analyze with Next.js |
| 19 | ANALYZE=true npm run build |
| 20 | |
| 21 | # Check what's in your bundle |
| 22 | npx source-map-explorer dist/assets/*.js |
| 1 | // ❌ Bad: importing entire library |
| 2 | import _ from "lodash"; // 70KB minified! |
| 3 | |
| 4 | // ✅ Good: import only what you need |
| 5 | import debounce from "lodash/debounce"; // 1KB |
| 6 | |
| 7 | // ❌ Bad: importing entire icon library |
| 8 | import { Icon } from "@icon-library"; |
| 9 | // Bundles ALL icons even if you use one |
| 10 | |
| 11 | // ✅ Good: import individual icons |
| 12 | import { HomeIcon } from "@icon-library/home"; |
| 13 | |
| 14 | // ✅ Good: use named exports for tree-shaking |
| 15 | import { format, parse } from "date-fns"; // only bundles format + parse |
| 16 | |
| 17 | // ❌ Bad: default exports from large libraries |
| 18 | import moment from "moment"; // 300KB, no tree-shaking |
| 19 | |
| 20 | // ✅ Good: modern alternatives |
| 21 | import { format, parseISO } from "date-fns"; // 5KB |
Never optimize blindly. Use the React DevTools Profiler to measure render times, identify bottlenecks, and verify that your optimizations actually help.
| 1 | // Programmatic profiling with Profiler API |
| 2 | import { Profiler } from "react"; |
| 3 | |
| 4 | function onRenderCallback( |
| 5 | id, |
| 6 | phase, |
| 7 | actualDuration, |
| 8 | baseDuration, |
| 9 | startTime, |
| 10 | commitTime |
| 11 | ) { |
| 12 | console.log(`Component ${id}:`); |
| 13 | console.log(` Phase: ${phase}`); |
| 14 | console.log(` Actual duration: ${actualDuration.toFixed(2)}ms`); |
| 15 | console.log(` Base duration: ${baseDuration.toFixed(2)}ms`); |
| 16 | } |
| 17 | |
| 18 | function App() { |
| 19 | return ( |
| 20 | <Profiler id="App" onRender={onRenderCallback}> |
| 21 | <Header /> |
| 22 | <Profiler id="Dashboard" onRender={onRenderCallback}> |
| 23 | <Dashboard /> |
| 24 | </Profiler> |
| 25 | <Profiler id="Sidebar" onRender={onRenderCallback}> |
| 26 | <Sidebar /> |
| 27 | </Profiler> |
| 28 | </Profiler> |
| 29 | ); |
| 30 | } |
| 31 | |
| 32 | // Production performance monitoring |
| 33 | function measurePerformance(name, fn) { |
| 34 | return (...args) => { |
| 35 | const start = performance.now(); |
| 36 | const result = fn(...args); |
| 37 | const duration = performance.now() - start; |
| 38 | |
| 39 | if (duration > 16) { // slower than 60fps |
| 40 | console.warn(`${name} took ${duration.toFixed(2)}ms`); |
| 41 | } |
| 42 | |
| 43 | return result; |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | // Track component render count in development |
| 48 | if (process.env.NODE_ENV === "development") { |
| 49 | function useRenderCount(componentName) { |
| 50 | const renderCount = useRef(0); |
| 51 | renderCount.current++; |
| 52 | useEffect(() => { |
| 53 | console.log(`${componentName} rendered ${renderCount.current} times`); |
| 54 | }); |
| 55 | } |
| 56 | } |
best practice
A practical checklist for React performance optimization:
| 1 | Performance Optimization Checklist |
| 2 | =================================== |
| 3 | |
| 4 | 1. MEASURE FIRST |
| 5 | □ Install React DevTools Profiler |
| 6 | □ Record interaction and identify slow components |
| 7 | □ Check bundle size (npm run build + visualizer) |
| 8 | □ Lighthouse audit for Core Web Vitals |
| 9 | |
| 10 | 2. COMPONENT LEVEL |
| 11 | □ Avoid unnecessary re-renders (React.memo) |
| 12 | □ Memoize expensive computations (useMemo) |
| 13 | □ Stabilize callback references (useCallback) |
| 14 | □ Keep components small and focused |
| 15 | □ Avoid creating objects/arrays in render |
| 16 | |
| 17 | 3. STATE LEVEL |
| 18 | □ Colocate state (don't lift state unnecessarily) |
| 19 | □ Split contexts for frequently changing values |
| 20 | □ Use state machines for complex state transitions |
| 21 | □ Derive state instead of storing duplicates |
| 22 | |
| 23 | 4. BUNDLE LEVEL |
| 24 | □ Lazy-load routes (React.lazy) |
| 25 | □ Lazy-load heavy components (charts, editors) |
| 26 | □ Remove unused dependencies |
| 27 | □ Use tree-shakeable imports |
| 28 | □ Analyze bundle with visualizer |
| 29 | |
| 30 | 5. RENDERING LEVEL |
| 31 | □ Virtualize long lists (>100 items) |
| 32 | □ Use image lazy loading (loading="lazy") |
| 33 | □ Debounce expensive event handlers |
| 34 | □ Use CSS for animations (not JavaScript) |
| 35 | |
| 36 | 6. DATA LEVEL |
| 37 | □ Cache API responses (TanStack Query / SWR) |
| 38 | □ Deduplicate requests |
| 39 | □ Prefetch data before navigation |
| 40 | □ Use optimistic updates for mutations |