|$ curl https://forge-ai.dev/api/markdown?path=docs/react/performance
$cat docs/performance.md
updated Recently·40 min read·published

Performance

ReactPerformanceOptimizationIntermediate to Advanced🎯Free Tools
Introduction

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.

Why Components Re-render

Understanding re-renders is the foundation of React performance. A component re-renders when:

why-rerenders.jsx
JSX
1// Trigger 1: State changes
2function 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)
9function 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
24function 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

Most unnecessary re-renders are fast and invisible. Only optimize when you observe actual performance issues (janky scrolling, slow typing, delayed responses). Premature optimization adds complexity without user-visible benefit.
React.memo

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.

react-memo.jsx
JSX
1import { memo, useState } from "react";
2
3// Without memo — re-renders every time parent re-renders
4function 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
16const 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
28const 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
48function 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

React.memo is not free — it adds comparison overhead. Only use it when profiling shows a component is re-rendering unnecessarily and the re-render is expensive. Memoizing every component is worse than memoizing none.
useMemo & useCallback

useMemo caches expensive computations. useCallback caches function references. Both prevent unnecessary re-renders of memoized children and expensive operations.

usememo-usecallback.jsx
JSX
1import { useMemo, useCallback, memo } from "react";
2
3// Expensive computation — memoize with useMemo
4function 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
36function 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
59const 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

useMemo and useCallback are only useful when combined with React.memo on child components. Without memo on the child, the parent still re-renders the child regardless of callback stability.
Lazy Loading & Code Splitting

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.

lazy-loading.jsx
JSX
1import { lazy, Suspense } from "react";
2
3// Lazy-load route components
4const Dashboard = lazy(() => import("./pages/Dashboard"));
5const Settings = lazy(() => import("./pages/Settings"));
6const Analytics = lazy(() => import("./pages/Analytics"));
7const AdminPanel = lazy(() => import("./pages/AdminPanel"));
8
9function 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
52function 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.)
70function 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
85const HeavyChart = lazy(() => import("./components/HeavyChart"));
🔥

pro tip

Next.js handles code splitting automatically with file-based routing — you don't need React.lazy. Use next/dynamic for component-level lazy loading in Next.js. For plain React apps, always wrap lazy components in Suspense.
Virtualization

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.

virtualization.jsx
JSX
1import { useVirtualizer } from "@tanstack/react-virtual";
2import { useRef } from "react";
3
4function 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
43function 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

Virtualize any list with more than 100 items. The performance difference is dramatic: a 10,000-item list without virtualization creates 10,000 DOM nodes; with virtualization, it creates only ~20 (visible + overscan). This reduces memory usage and speeds up rendering.
Bundle Optimization

Reducing bundle size means faster page loads. Analyze your bundle, remove unused code, and use tree-shaking-friendly libraries.

bundle-analysis.txt
Bash
1# Analyze bundle size with Vite
2npm install -D rollup-plugin-visualizer
3
4// vite.config.ts
5import { visualizer } from "rollup-plugin-visualizer";
6
7export 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
19ANALYZE=true npm run build
20
21# Check what's in your bundle
22npx source-map-explorer dist/assets/*.js
bundle-optimization.jsx
JSX
1// ❌ Bad: importing entire library
2import _ from "lodash"; // 70KB minified!
3
4// ✅ Good: import only what you need
5import debounce from "lodash/debounce"; // 1KB
6
7// ❌ Bad: importing entire icon library
8import { Icon } from "@icon-library";
9// Bundles ALL icons even if you use one
10
11// ✅ Good: import individual icons
12import { HomeIcon } from "@icon-library/home";
13
14// ✅ Good: use named exports for tree-shaking
15import { format, parse } from "date-fns"; // only bundles format + parse
16
17// ❌ Bad: default exports from large libraries
18import moment from "moment"; // 300KB, no tree-shaking
19
20// ✅ Good: modern alternatives
21import { format, parseISO } from "date-fns"; // 5KB
Profiling & Measurement

Never optimize blindly. Use the React DevTools Profiler to measure render times, identify bottlenecks, and verify that your optimizations actually help.

profiling.jsx
JSX
1// Programmatic profiling with Profiler API
2import { Profiler } from "react";
3
4function 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
18function 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
33function 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
48if (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

Install the React DevTools browser extension and use its Profiler tab. Record interactions, check which components re-rendered, and measure their render time. Optimize the components that take the longest or re-render most frequently — not everything.
Performance Checklist

A practical checklist for React performance optimization:

checklist.txt
TEXT
1Performance Optimization Checklist
2===================================
3
41. 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
102. 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
173. 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
234. 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
305. 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
366. DATA LEVEL
37 □ Cache API responses (TanStack Query / SWR)
38 □ Deduplicate requests
39 □ Prefetch data before navigation
40 □ Use optimistic updates for mutations