React applications can become slow as they grow in complexity. This guide covers advanced performance optimization techniques to keep your React apps lightning fast.
Understanding React Performance
Before optimizing, itβs crucial to understand how React works:
- Virtual DOM: React maintains a virtual representation of the DOM
- Reconciliation: Process of comparing virtual DOM trees to determine minimal changes
- Re-rendering: When component state or props change, React re-renders the component tree
Performance Measurement Tools
1. React DevTools Profiler
// Wrap components you want to profile
import { Profiler } from 'react';
function onRenderCallback(
id: string,
phase: 'mount' | 'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number
) {
console.log('Component:', id, 'Phase:', phase, 'Duration:', actualDuration);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<MainComponent />
</Profiler>
);
}
2. Performance.mark API
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
performance.mark('MyComponent-render-start');
return () => {
performance.mark('MyComponent-render-end');
performance.measure(
'MyComponent-render',
'MyComponent-render-start',
'MyComponent-render-end'
);
};
});
// Component logic
}
Memoization Techniques
1. React.memo for Component Memoization
interface UserCardProps {
user: User;
onClick: (id: string) => void;
}
// Only re-renders if props actually change
const UserCard = React.memo<UserCardProps>(({ user, onClick }) => {
return (
<div onClick={() => onClick(user.id)}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
});
// Custom comparison function for complex objects
const UserCardWithCustomComparison = React.memo<UserCardProps>(
({ user, onClick }) => {
// Component implementation
},
(prevProps, nextProps) => {
return (
prevProps.user.id === nextProps.user.id &&
prevProps.user.name === nextProps.user.name &&
prevProps.onClick === nextProps.onClick
);
}
);
2. useMemo for Expensive Calculations
import { useMemo } from 'react';
interface DataTableProps {
data: Item[];
filters: FilterConfig;
sortBy: SortConfig;
}
function DataTable({ data, filters, sortBy }: DataTableProps) {
// Expensive operation - only recalculate when dependencies change
const processedData = useMemo(() => {
return data
.filter(item => applyFilters(item, filters))
.sort((a, b) => applySorting(a, b, sortBy))
.slice(0, 100); // Pagination
}, [data, filters, sortBy]);
return (
<div>
{processedData.map(item => (
<DataRow key={item.id} item={item} />
))}
</div>
);
}
3. useCallback for Function Memoization
import { useCallback, useState } from 'react';
interface TodoListProps {
initialTodos: Todo[];
}
function TodoList({ initialTodos }: TodoListProps) {
const [todos, setTodos] = useState(initialTodos);
// Prevent unnecessary re-renders of child components
const handleToggle = useCallback((id: string) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}, []); // Empty dependency array - function never changes
const handleDelete = useCallback((id: string) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
}, []);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
}
Code Splitting and Lazy Loading
1. Route-based Code Splitting
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
2. Component-based Code Splitting
import { Suspense, lazy, useState } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const DataTable = lazy(() => import('./DataTable'));
function Dashboard() {
const [activeTab, setActiveTab] = useState<'chart' | 'table'>('chart');
return (
<div>
<nav>
<button onClick={() => setActiveTab('chart')}>Chart</button>
<button onClick={() => setActiveTab('table')}>Table</button>
</nav>
<Suspense fallback={<div>Loading component...</div>}>
{activeTab === 'chart' && <HeavyChart />}
{activeTab === 'table' && <DataTable />}
</Suspense>
</div>
);
}
Virtual Scrolling for Large Lists
import { FixedSizeList as List } from 'react-window';
interface VirtualizedListProps {
items: Item[];
}
function VirtualizedList({ items }: VirtualizedListProps) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<div style={style}>
<ItemComponent item={items[index]} />
</div>
);
return (
<List
height={600} // Container height
itemCount={items.length}
itemSize={80} // Height of each item
width="100%"
>
{Row}
</List>
);
}
State Management Optimization
1. Minimize State Updates
// β Bad - Multiple state updates
function BadComponent() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const userData = await api.getUser();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
}
// β
Good - Single state update with useReducer
interface UserState {
user: User | null;
loading: boolean;
error: string | null;
}
type UserAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User }
| { type: 'FETCH_ERROR'; payload: string };
function userReducer(state: UserState, action: UserAction): UserState {
switch (action.type) {
case 'FETCH_START':
return { ...state, loading: true, error: null };
case 'FETCH_SUCCESS':
return { user: action.payload, loading: false, error: null };
case 'FETCH_ERROR':
return { ...state, loading: false, error: action.payload };
default:
return state;
}
}
function GoodComponent() {
const [state, dispatch] = useReducer(userReducer, {
user: null,
loading: false,
error: null
});
const fetchUser = async () => {
dispatch({ type: 'FETCH_START' });
try {
const userData = await api.getUser();
dispatch({ type: 'FETCH_SUCCESS', payload: userData });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', payload: err.message });
}
};
}
2. Context Optimization
// β Bad - Single large context
interface AppContextValue {
user: User;
theme: Theme;
notifications: Notification[];
// ... many other values
}
// β
Good - Split contexts by concern
interface UserContextValue {
user: User;
updateUser: (user: User) => void;
}
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
// Use multiple providers
function App() {
return (
<UserProvider>
<ThemeProvider>
<NotificationProvider>
<MainApp />
</NotificationProvider>
</ThemeProvider>
</UserProvider>
);
}
Performance Best Practices
1. Avoid Inline Objects and Functions
// β Bad - Creates new objects on every render
function BadComponent() {
return (
<div>
<UserProfile
style={{ margin: 10, padding: 20 }} // New object every render
onClick={() => console.log('clicked')} // New function every render
/>
</div>
);
}
// β
Good - Define outside or use useMemo/useCallback
const profileStyle = { margin: 10, padding: 20 };
function GoodComponent() {
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return (
<div>
<UserProfile
style={profileStyle}
onClick={handleClick}
/>
</div>
);
}
2. Use Keys Properly
// β Bad - Using array index as key
function BadList({ items }: { items: Item[] }) {
return (
<div>
{items.map((item, index) => (
<ItemComponent key={index} item={item} />
))}
</div>
);
}
// β
Good - Using stable, unique identifier
function GoodList({ items }: { items: Item[] }) {
return (
<div>
{items.map(item => (
<ItemComponent key={item.id} item={item} />
))}
</div>
);
}
Bundle Optimization
1. Analyze Bundle Size
# Using webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer
# Add to package.json scripts
"analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"
2. Tree Shaking
// β Bad - Imports entire library
import * as _ from 'lodash';
// β
Good - Import only what you need
import { debounce, throttle } from 'lodash';
// or
import debounce from 'lodash/debounce';
Conclusion
React performance optimization is an ongoing process that requires:
- Measurement first - Profile before optimizing
- Strategic memoization - Donβt memo everything, focus on expensive operations
- Smart code splitting - Load only what users need when they need it
- Efficient state management - Minimize re-renders and state updates
- Bundle optimization - Keep your JavaScript payloads lean
Remember: premature optimization is the root of all evil. Always measure, identify bottlenecks, then optimize strategically!