React Performance Optimization
useMemo, useCallback, React.memo, code splitting, and profiling strategies
Core Patterns
- When to Read This
- Profiling First
- useMemo
- useCallback
When to Read This
- Components re-rendering unnecessarily
- Expensive computations causing lag
- Large lists with performance issues
- Optimizing production bundle size
- Profiling with React DevTools
Profiling First
⚠️ CRITICAL: Profile Before Optimizing
// ❌ WRONG: Premature optimization
const expensiveValue = useMemo(() => x + y, [x, y]); // Not expensive!
// ✅ CORRECT: Profile first, optimize when needed
// 1. Open React DevTools Profiler
// 2. Record interaction
// 3. Identify actual bottlenecks
// 4. Apply specific optimization
Rules:
- Don’t optimize without measuring
- Profile in production mode (
npm run build) - Focus on actual user interactions
- Optimize hot paths, not every component
useMemo
✅ When to Use
Memoize expensive computations that run on every render:
function DataGrid({ items, filter }) {
// ✅ CORRECT: Expensive filtering memoized
const filteredItems = useMemo(() => {
console.log('Filtering...'); // Only logs when items/filter change
return items
.filter(item => item.name.includes(filter))
.sort((a, b) => a.date - b.date);
}, [items, filter]);
return <div>{filteredItems.map(item => <Item key={item.id} {...item} />)}</div>;
}
❌ When NOT to Use
// ❌ WRONG: Cheap computation (overhead > benefit)
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName],
);
// ✅ CORRECT: Direct computation
const fullName = `${firstName} ${lastName}`;
// ❌ WRONG: items.length is cheap
const count = useMemo(() => items.length, [items]);
// ✅ CORRECT: Direct access
const count = items.length;
✅ Complex Calculations
function Chart({ data }) {
const statistics = useMemo(() => {
return {
mean: calculateMean(data),
median: calculateMedian(data),
stdDev: calculateStandardDeviation(data),
percentiles: calculatePercentiles(data),
};
}, [data]);
return <ChartComponent stats={statistics} />;
}
✅ Object/Array Identity
function Parent() {
const [count, setCount] = useState(0);
// ❌ WRONG: New array on every render
const items = [1, 2, 3].map(n => ({ id: n, value: n * count }));
// ✅ CORRECT: Stable identity when dependencies don't change
const items = useMemo(() => {
return [1, 2, 3].map(n => ({ id: n, value: n * count }));
}, [count]);
return <MemoizedChild items={items} />;
}
useCallback
✅ When to Use
Memoize callback functions passed to memoized children:
const MemoizedChild = React.memo(({ onClick }) => {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
const [other, setOther] = useState(0);
// ❌ WRONG: New function on every render
const handleClick = () => {
setCount(count + 1);
};
// MemoizedChild re-renders every time Parent renders
// ✅ CORRECT: Stable function reference
const handleClick = useCallback(() => {
setCount(c => c + 1); // Functional update, no dependency
}, []);
// MemoizedChild only renders when needed
return (
<div>
<button onClick={() => setOther(other + 1)}>Other: {other}</button>
<MemoizedChild onClick={handleClick} />
</div>
);
}
❌ When NOT to Use
// ❌ WRONG: Callback not passed to memoized component
function Component() {
const handleClick = useCallback(() => {
console.log('Clicked');
}, []); // No benefit if not passed to memo() child
return <button onClick={handleClick}>Click</button>;
}
// ✅ CORRECT: Just use regular function
function Component() {
const handleClick = () => console.log('Clicked');
return <button onClick={handleClick}>Click</button>;
}
✅ With External Dependencies
function Search({ initialQuery }) {
const [results, setResults] = useState([]);
// ✅ CORRECT: Memoized with dependencies
const handleSearch = useCallback(async (query: string) => {
const data = await searchAPI(query);
setResults(data);
}, []); // No external dependencies
// ❌ PROBLEM: Missing initialQuery dependency
const handleSearchWithFilter = useCallback(async (query: string) => {
const data = await searchAPI(query, initialQuery);
setResults(data);
}, []); // Missing initialQuery dependency!
// ✅ CORRECT: Include dependency
const handleSearchWithFilter = useCallback(async (query: string) => {
const data = await searchAPI(query, initialQuery);
setResults(data);
}, [initialQuery]);
return <SearchInput onSearch={handleSearch} />;
}
React.memo
✅ Memoize Components
// ❌ WRONG: Re-renders when parent re-renders (even if props same)
function ExpensiveChild({ data }) {
console.log('Expensive render');
return <ComplexVisualization data={data} />;
}
// ✅ CORRECT: Only re-renders when props change
const ExpensiveChild = React.memo(({ data }) => {
console.log('Expensive render');
return <ComplexVisualization data={data} />;
});
✅ Custom Comparison
interface Props {
user: User;
metadata: Metadata;
}
// Only re-render when user.id changes
const UserCard = React.memo(
({ user, metadata }: Props) => {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return prevProps.user.id === nextProps.user.id;
}
);
⚠️ Gotchas: Object/Function Props
function Parent() {
const [count, setCount] = useState(0);
// ❌ PROBLEM: New object on every render
const config = { theme: 'dark', size: 'large' };
// ❌ PROBLEM: New function on every render
const handleClick = () => console.log('Click');
// MemoizedChild ALWAYS re-renders (new props)
return <MemoizedChild config={config} onClick={handleClick} />;
}
// ✅ SOLUTION: useMemo + useCallback
function Parent() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: 'dark', size: 'large' }), []);
const handleClick = useCallback(() => console.log('Click'), []);
return <MemoizedChild config={config} onClick={handleClick} />;
}
List Rendering Optimization
✅ Virtualization for Long Lists
import { FixedSizeList } from 'react-window';
// ❌ WRONG: Render all 10,000 items
function LongList({ items }) {
return (
<div>
{items.map(item => <Item key={item.id} {...item} />)}
</div>
);
}
// ✅ CORRECT: Virtualized (only render visible items)
function VirtualizedList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
<Item {...items[index]} />
</div>
)}
</FixedSizeList>
);
}
✅ Stable Keys
// ❌ WRONG: Index as key (bugs on reorder/delete)
{items.map((item, index) => <Item key={index} {...item} />)}
// ✅ CORRECT: Unique stable ID
{items.map(item => <Item key={item.id} {...item} />)}
✅ Memoize List Items
// ❌ WRONG: All items re-render when one changes
function TodoList({ todos, onToggle }) {
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
))}
</ul>
);
}
// ✅ CORRECT: Each item only re-renders when its data changes
const TodoItem = React.memo(({ todo, onToggle }) => {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</li>
);
});
function TodoList({ todos, onToggle }) {
const handleToggle = useCallback((id) => onToggle(id), [onToggle]);
return (
<ul>
{todos.map(todo => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</ul>
);
}
Code Splitting
✅ Lazy Loading Components
import { lazy, Suspense } from 'react';
// ❌ WRONG: Bundle all routes together
import Dashboard from './Dashboard';
import Settings from './Settings';
import Profile from './Profile';
// ✅ CORRECT: Code split by route
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
const Profile = lazy(() => import('./Profile'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}
✅ Named Exports with Lazy
// Component.tsx
export function MyComponent() {
return <div>Component</div>;
}
// App.tsx
const MyComponent = lazy(() =>
import('./Component').then(module => ({ default: module.MyComponent }))
);
✅ Preloading
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// Preload on hover
function App() {
const handleMouseEnter = () => {
import('./HeavyComponent'); // Start loading
};
return (
<div>
<button onMouseEnter={handleMouseEnter}>
Show Heavy Component
</button>
<Suspense fallback={<div>Loading...</div>}>
{showHeavy && <HeavyComponent />}
</Suspense>
</div>
);
}
State Optimization
✅ State Colocation
// ❌ WRONG: State at top level (re-renders entire tree)
function App() {
const [formData, setFormData] = useState({});
return (
<div>
<Header />
<Sidebar />
<Form data={formData} onChange={setFormData} /> {/* Only Form needs this */}
<Footer />
</div>
);
}
// ✅ CORRECT: State close to where it's used
function App() {
return (
<div>
<Header />
<Sidebar />
<FormContainer /> {/* State inside here */}
<Footer />
</div>
);
}
function FormContainer() {
const [formData, setFormData] = useState({});
return <Form data={formData} onChange={setFormData} />;
}
✅ Split State
// ❌ WRONG: One big state object (all updates trigger full re-render)
const [state, setState] = useState({
user: null,
theme: "dark",
sidebarOpen: false,
notifications: [],
});
// ✅ CORRECT: Separate state by update frequency
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("dark");
const [sidebarOpen, setSidebarOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
Bundle Size Optimization
✅ Import Only What You Need
// ❌ WRONG: Import entire library
import _ from "lodash"; // 70KB+
const result = _.debounce(fn, 500);
// ✅ CORRECT: Import specific function
import debounce from "lodash/debounce"; // 3KB
const result = debounce(fn, 500);
✅ Analyze Bundle
# Generate bundle analysis
npm run build -- --stats
npx webpack-bundle-analyzer dist/stats.json
# Or with Vite
npm run build
npx vite-bundle-visualizer
Profiling with React DevTools
✅ Recording Profile
- Open React DevTools → Profiler tab
- Click record button
- Perform the action you want to profile
- Stop recording
- Analyze flame graph and ranked chart
✅ Interpreting Results
Flame Graph:
- Width = time spent rendering
- Color = component type (yellow = slow)
- Height = component depth
Ranked Chart:
- Shows components ordered by render time
- Focus on top 5-10 slowest components
Commit Timeline:
- Each bar = one render commit
- Height = total render time
- Click to see which components rendered
Common Performance Patterns
✅ Debounced Search
function SearchBox() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
useEffect(() => {
if (debouncedSearch) {
searchAPI(debouncedSearch).then(setResults);
}
}, [debouncedSearch]);
return <input value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />;
}
✅ Windowed Rendering
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }) {
const parentRef = useRef();
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualItem => (
<div key={virtualItem.index} style={{ transform: `translateY(${virtualItem.start}px)` }}>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
);
}
Concurrent Features (React 18+)
useTransition for Heavy Renders
Keep the UI responsive during expensive state updates.
function ProductCatalog() {
const [filter, setFilter] = useState('');
const [filteredProducts, setFilteredProducts] = useState(products);
const [isPending, startTransition] = useTransition();
const handleFilter = (value: string) => {
setFilter(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: filter 10,000 products without blocking input
setFilteredProducts(products.filter(p =>
p.name.toLowerCase().includes(value.toLowerCase())
));
});
};
return (
<>
<input value={filter} onChange={e => handleFilter(e.target.value)} />
{isPending ? <Skeleton count={10} /> : <ProductGrid items={filteredProducts} />}
</>
);
}
useDeferredValue for Search/Filter UX
function SearchableList({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(
() => items.filter(item => item.name.includes(deferredQuery)),
[items, deferredQuery]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: query !== deferredQuery ? 0.6 : 1 }}>
{filtered.map(item => <Item key={item.id} item={item} />)}
</div>
</>
);
}
Suspense for Data Loading
// ✅ CORRECT: Suspense with lazy components
const HeavyDashboard = lazy(() => import('./HeavyDashboard'));
function App() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<HeavyDashboard />
</Suspense>
);
}
// ✅ CORRECT: Nested Suspense for progressive loading
function Dashboard() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ChartSkeleton />}>
<ExpensiveChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</Suspense>
);
}
Route-Based Code Splitting
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// Preload on hover/focus for instant navigation
const preloadDashboard = () => import('./pages/Dashboard');
function App() {
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
function PreloadLink({ to, children, preload }: PreloadLinkProps) {
return (
<Link to={to} onMouseEnter={preload} onFocus={preload}>
{children}
</Link>
);
}