React Performance Optimization: Complete Guide to Fast User Interfaces
이 글의 핵심
Complete React performance optimization guide with real-world examples. Learn React.memo, hooks optimization, code splitting, bundle analysis, and advanced patterns for building high-performance React applications.
Understanding React Performance Fundamentals
React performance optimization requires understanding how React works under the hood: the virtual DOM diffing process, component reconciliation, and when re-renders occur. The key insight is that React’s default behavior is to re-render everything when state changes, which can become expensive as your app grows.
Modern React applications face performance challenges from multiple sources: unnecessary re-renders, expensive computations, large bundle sizes, and inefficient state management. Addressing these systematically will dramatically improve user experience.
The most effective optimization strategy combines preventing unnecessary work (memoization), splitting work efficiently (code splitting), and measuring everything (performance monitoring). Let’s explore each area with practical, production-ready examples.
Preventing Unnecessary Re-renders
React.memo for Component Optimization
React.memo prevents functional components from re-rendering when their props haven’t changed:
import React, { useState, memo } from 'react';
// Expensive component that should only render when props change
const ExpensiveUserCard = memo(({ user, onEdit }) => {
console.log('ExpensiveUserCard rendering for:', user.name);
// Simulate expensive computation
const processedData = React.useMemo(() => {
return {
...user,
displayName: `${user.firstName} ${user.lastName}`.toUpperCase(),
initials: `${user.firstName[0]}${user.lastName[0]}`,
// Expensive calculation that should be memoized
riskScore: calculateComplexRiskScore(user)
};
}, [user]);
return (
<div className="user-card">
<div className="user-avatar">{processedData.initials}</div>
<div className="user-info">
<h3>{processedData.displayName}</h3>
<p>Risk Score: {processedData.riskScore}</p>
<button onClick={() => onEdit(user.id)}>Edit User</button>
</div>
</div>
);
});
// Custom comparison for complex props
const UserList = memo(({ users, filters, onEditUser }) => {
const filteredUsers = React.useMemo(() => {
return users.filter(user => {
if (filters.status && user.status !== filters.status) return false;
if (filters.department && user.department !== filters.department) return false;
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
return user.firstName.toLowerCase().includes(searchTerm) ||
user.lastName.toLowerCase().includes(searchTerm);
}
return true;
});
}, [users, filters]);
return (
<div className="user-list">
{filteredUsers.map(user => (
<ExpensiveUserCard
key={user.id}
user={user}
onEdit={onEditUser}
/>
))}
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison logic for complex props
return (
prevProps.users === nextProps.users &&
prevProps.filters === nextProps.filters &&
prevProps.onEditUser === nextProps.onEditUser
);
});
function calculateComplexRiskScore(user) {
// Simulate expensive computation
let score = 0;
for (let i = 0; i < 1000; i++) {
score += user.transactions?.length || 0;
score += user.loginCount || 0;
}
return score % 100;
}
Hook Optimization Patterns
Strategic use of useCallback and useMemo prevents expensive recalculations:
import React, { useState, useCallback, useMemo } from 'react';
const OptimizedUserDashboard = () => {
const [users, setUsers] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' });
const [selectedUsers, setSelectedUsers] = useState(new Set());
// Memoize expensive filtering and sorting
const processedUsers = useMemo(() => {
console.log('Recomputing processed users');
let filtered = users;
// Apply search filter
if (searchTerm) {
filtered = filtered.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.email.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Apply sorting
filtered = [...filtered].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
return filtered;
}, [users, searchTerm, sortConfig]);
// Memoize statistics calculation
const statistics = useMemo(() => {
console.log('Recomputing statistics');
return {
totalUsers: users.length,
activeUsers: users.filter(u => u.isActive).length,
selectedCount: selectedUsers.size,
averageAge: users.reduce((sum, u) => sum + u.age, 0) / users.length || 0
};
}, [users, selectedUsers.size]);
// Memoize event handlers to prevent child re-renders
const handleUserSelect = useCallback((userId) => {
setSelectedUsers(prev => {
const newSet = new Set(prev);
if (newSet.has(userId)) {
newSet.delete(userId);
} else {
newSet.add(userId);
}
return newSet;
});
}, []);
const handleSort = useCallback((key) => {
setSortConfig(prevConfig => ({
key,
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
}));
}, []);
const handleBulkAction = useCallback((action) => {
console.log(`Performing ${action} on ${selectedUsers.size} users`);
// Implement bulk actions
setSelectedUsers(new Set());
}, [selectedUsers.size]);
// Debounced search to prevent excessive filtering
const debouncedSearch = useCallback(
debounce((term) => setSearchTerm(term), 300),
[]
);
return (
<div className="user-dashboard">
<DashboardHeader
statistics={statistics}
onBulkAction={handleBulkAction}
hasSelection={selectedUsers.size > 0}
/>
<SearchAndFilter
onSearch={debouncedSearch}
onSort={handleSort}
sortConfig={sortConfig}
/>
<UserList
users={processedUsers}
selectedUsers={selectedUsers}
onUserSelect={handleUserSelect}
/>
</div>
);
};
// Utility function for debouncing
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
Advanced Memoization Strategies
Context Optimization
Prevent context re-renders from cascading through your component tree:
import React, { createContext, useContext, useMemo, useState } from 'react';
// Split contexts by update frequency
const UserDataContext = createContext();
const UserActionsContext = createContext();
const UserProvider = ({ children }) => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Memoize actions to prevent unnecessary re-renders
const actions = useMemo(() => ({
addUser: async (userData) => {
setLoading(true);
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
const newUser = await response.json();
setUsers(prev => [...prev, newUser]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
},
updateUser: async (userId, updates) => {
setUsers(prev => prev.map(user =>
user.id === userId ? { ...user, ...updates } : user
));
},
deleteUser: async (userId) => {
setUsers(prev => prev.filter(user => user.id !== userId));
}
}), []); // Actions don't depend on state, so empty deps
// Memoize data to prevent re-renders when actions haven't changed
const userData = useMemo(() => ({
users,
loading,
error
}), [users, loading, error]);
return (
<UserActionsContext.Provider value={actions}>
<UserDataContext.Provider value={userData}>
{children}
</UserDataContext.Provider>
</UserActionsContext.Provider>
);
};
// Custom hooks for consuming context
const useUserData = () => {
const context = useContext(UserDataContext);
if (!context) {
throw new Error('useUserData must be used within UserProvider');
}
return context;
};
const useUserActions = () => {
const context = useContext(UserActionsContext);
if (!context) {
throw new Error('useUserActions must be used within UserProvider');
}
return context;
};
// Component that only needs actions (won't re-render when data changes)
const AddUserForm = memo(() => {
const { addUser } = useUserActions();
const [formData, setFormData] = useState({ name: '', email: '' });
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
await addUser(formData);
setFormData({ name: '', email: '' });
}, [addUser, formData]);
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Name"
/>
<input
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder="Email"
/>
<button type="submit">Add User</button>
</form>
);
});
// Component that only needs data (won't re-render when actions change)
const UserStats = memo(() => {
const { users } = useUserData();
const stats = useMemo(() => ({
total: users.length,
active: users.filter(u => u.isActive).length,
premium: users.filter(u => u.isPremium).length
}), [users]);
return (
<div className="user-stats">
<div>Total: {stats.total}</div>
<div>Active: {stats.active}</div>
<div>Premium: {stats.premium}</div>
</div>
);
});
Virtualization for Large Lists
Handle thousands of items efficiently with windowing:
import React, { memo, useMemo } from 'react';
import { FixedSizeList as List } from 'react-window';
const VirtualizedUserList = memo(({ users, onUserClick, searchTerm }) => {
// Filter users based on search
const filteredUsers = useMemo(() => {
if (!searchTerm) return users;
return users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [users, searchTerm]);
// Row renderer for react-window
const Row = memo(({ index, style }) => {
const user = filteredUsers[index];
return (
<div style={style} className="user-row">
<UserRowContent user={user} onUserClick={onUserClick} />
</div>
);
});
// Calculate item size dynamically if needed
const getItemSize = useCallback((index) => {
// Return dynamic height based on content
const user = filteredUsers[index];
return user.isExpanded ? 120 : 60;
}, [filteredUsers]);
return (
<List
height={600}
itemCount={filteredUsers.length}
itemSize={60}
itemData={filteredUsers}
overscanCount={5} // Render extra items for smooth scrolling
>
{Row}
</List>
);
});
const UserRowContent = memo(({ user, onUserClick }) => {
const handleClick = useCallback(() => {
onUserClick(user.id);
}, [user.id, onUserClick]);
return (
<div className="user-content" onClick={handleClick}>
<img src={user.avatar} alt={user.name} className="user-avatar" />
<div className="user-info">
<h4>{user.name}</h4>
<p>{user.email}</p>
</div>
<div className="user-status">
{user.isActive ? '🟢' : '🔴'}
</div>
</div>
);
});
Code Splitting and Lazy Loading
Route-Based Code Splitting
Split your application by routes to reduce initial bundle size:
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/LoadingSpinner';
import ErrorBoundary from './components/ErrorBoundary';
// Lazy load route components
const Dashboard = lazy(() => import('./pages/Dashboard'));
const UserManagement = lazy(() => import('./pages/UserManagement'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
// Preload critical routes
const preloadUserManagement = () => import('./pages/UserManagement');
const preloadAnalytics = () => import('./pages/Analytics');
const App = () => {
React.useEffect(() => {
// Preload likely next pages after initial load
const timer = setTimeout(() => {
preloadUserManagement();
preloadAnalytics();
}, 2000);
return () => clearTimeout(timer);
}, []);
return (
<BrowserRouter>
<div className="app">
<Navigation
onUserManagementHover={preloadUserManagement}
onAnalyticsHover={preloadAnalytics}
/>
<main className="main-content">
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</ErrorBoundary>
</main>
</div>
</BrowserRouter>
);
};
// Navigation with hover preloading
const Navigation = memo(({ onUserManagementHover, onAnalyticsHover }) => {
return (
<nav className="navigation">
<Link to="/">Dashboard</Link>
<Link
to="/users"
onMouseEnter={onUserManagementHover}
>
Users
</Link>
<Link
to="/analytics"
onMouseEnter={onAnalyticsHover}
>
Analytics
</Link>
<Link to="/settings">Settings</Link>
</nav>
);
});
Component-Level Code Splitting
Split large components or feature sets dynamically:
import React, { useState, Suspense, lazy } from 'react';
// Lazy load heavy components
const ChartComponent = lazy(() => import('./ChartComponent'));
const DataTable = lazy(() => import('./DataTable'));
const ReportGenerator = lazy(() => import('./ReportGenerator'));
// Preload function for better UX
const preloadChart = () => import('./ChartComponent');
const AnalyticsDashboard = () => {
const [activeTab, setActiveTab] = useState('overview');
const [chartLoaded, setChartLoaded] = useState(false);
const handleTabChange = (tab) => {
setActiveTab(tab);
// Preload chart when user shows interest
if (tab === 'charts' && !chartLoaded) {
preloadChart().then(() => setChartLoaded(true));
}
};
return (
<div className="analytics-dashboard">
<TabNavigation
activeTab={activeTab}
onTabChange={handleTabChange}
onChartsHover={preloadChart}
/>
<div className="tab-content">
{activeTab === 'overview' && (
<OverviewTab />
)}
{activeTab === 'charts' && (
<Suspense fallback={<ChartSkeleton />}>
<ChartComponent />
</Suspense>
)}
{activeTab === 'data' && (
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
)}
{activeTab === 'reports' && (
<Suspense fallback={<ReportSkeleton />}>
<ReportGenerator />
</Suspense>
)}
</div>
</div>
);
};
// Skeleton components for better perceived performance
const ChartSkeleton = () => (
<div className="chart-skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-chart"></div>
</div>
);
Bundle Optimization and Analysis
Webpack Bundle Analysis
Understand and optimize your bundle composition:
// webpack.config.js optimizations
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// Separate vendor libraries
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
chunks: 'all',
},
// Common chunks across multiple entries
common: {
minChunks: 2,
priority: 5,
reuseExistingChunk: true,
},
// Large libraries get their own chunks
lodash: {
test: /[\\/]node_modules[\\/](lodash)[\\/]/,
name: 'lodash',
priority: 20,
chunks: 'all',
},
moment: {
test: /[\\/]node_modules[\\/](moment)[\\/]/,
name: 'moment',
priority: 20,
chunks: 'all',
},
},
},
// Generate runtime chunk
runtimeChunk: {
name: 'runtime',
},
},
// Tree shaking optimization
resolve: {
alias: {
// Use ES modules version of lodash for better tree shaking
'lodash': 'lodash-es',
},
},
};
// Package.json script for bundle analysis
{
"scripts": {
"analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"
}
}
Tree Shaking Optimization
Ensure unused code is eliminated:
// utils/index.js - Poor tree shaking (imports everything)
export * from './dateUtils';
export * from './stringUtils';
export * from './arrayUtils';
// utils/index.js - Better tree shaking (explicit exports)
export { formatDate, parseDate } from './dateUtils';
export { capitalize, truncate } from './stringUtils';
export { chunk, unique } from './arrayUtils';
// Component usage - Import only what you need
import { formatDate, capitalize } from './utils';
// Avoid importing entire libraries
// ❌ Bad - imports entire lodash
import _ from 'lodash';
// ✅ Good - imports only needed functions
import { debounce, throttle } from 'lodash-es';
// ✅ Even better - individual imports
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';
Performance Monitoring and Debugging
React DevTools Integration
Implement comprehensive performance monitoring:
import React, { Profiler, useState } from 'react';
// Performance monitoring wrapper
const PerformanceWrapper = ({ children, id }) => {
const [measurements, setMeasurements] = useState([]);
const onRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
// Log performance data
const measurement = {
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
timestamp: Date.now()
};
setMeasurements(prev => [...prev.slice(-50), measurement]); // Keep last 50 measurements
// Alert on performance issues
if (actualDuration > 16) { // More than one frame
console.warn(`Slow render in ${id}: ${actualDuration.toFixed(2)}ms`);
}
};
return (
<Profiler id={id} onRender={onRenderCallback}>
{children}
</Profiler>
);
};
// Custom hook for performance tracking
const usePerformanceTracker = (componentName) => {
const [renderCount, setRenderCount] = useState(0);
React.useEffect(() => {
setRenderCount(prev => prev + 1);
});
React.useEffect(() => {
if (renderCount > 0) {
console.log(`${componentName} rendered ${renderCount} times`);
}
}, [renderCount, componentName]);
return { renderCount };
};
// Usage in components
const MonitoredComponent = () => {
const { renderCount } = usePerformanceTracker('MonitoredComponent');
return (
<PerformanceWrapper id="monitored-component">
<div>Render count: {renderCount}</div>
{/* Component content */}
</PerformanceWrapper>
);
};
Custom Performance Hooks
Build reusable performance utilities:
// hooks/useRenderOptimization.js
import { useRef, useCallback, useMemo } from 'react';
export const useStableCallback = (callback, deps) => {
const callbackRef = useRef(callback);
const depsRef = useRef(deps);
// Update callback if dependencies changed
if (!deps || deps.some((dep, i) => dep !== depsRef.current?.[i])) {
callbackRef.current = callback;
depsRef.current = deps;
}
return useCallback((...args) => {
return callbackRef.current(...args);
}, []);
};
export const useDeepMemo = (factory, deps) => {
const depsRef = useRef();
const resultRef = useRef();
if (!depsRef.current || !deepEqual(deps, depsRef.current)) {
depsRef.current = deps;
resultRef.current = factory();
}
return resultRef.current;
};
// Deep equality check (simplified)
function deepEqual(a, b) {
if (a === b) return true;
if (!a || !b) return false;
if (typeof a !== 'object' || typeof b !== 'object') return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (let key of keysA) {
if (!keysB.includes(key) || !deepEqual(a[key], b[key])) {
return false;
}
}
return true;
}
// Usage example
const OptimizedComponent = ({ complexData, onUpdate }) => {
// Stable callback that doesn't cause re-renders
const handleUpdate = useStableCallback((id, changes) => {
onUpdate(id, changes);
}, [onUpdate]);
// Deep memoization for complex objects
const processedData = useDeepMemo(() => {
return complexData.map(item => ({
...item,
computed: expensiveComputation(item)
}));
}, [complexData]);
return (
<div>
{processedData.map(item => (
<ItemComponent
key={item.id}
item={item}
onUpdate={handleUpdate}
/>
))}
</div>
);
};
React performance optimization is about making deliberate choices based on your application’s specific needs. Start with measuring performance, identify bottlenecks, then apply targeted optimizations. The goal isn’t to optimize everything, but to optimize the right things at the right time.
Focus on user-perceived performance: fast initial loads, smooth interactions, and responsive interfaces. Use the tools and patterns shown here to build React applications that scale efficiently and provide excellent user experiences regardless of complexity.