Finding and Fixing Slow React Renders: memo, useMemo, useCallback, Code Splitting and Profiling
Key takeaways
How to find why a React app re-renders too much and fix it: React.memo, useMemo and useCallback used where they pay off, code splitting and lazy loading, bundle analysis, and performance monitoring.
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;
}
React.memo only helps if the props themselves are stable across renders — it does a shallow comparison of each prop, so passing a fresh inline object or array literal as a prop (user={{ ...someUser }}, onEdit={() => handleEdit(id)}) defeats it every time, since the new object is never === the old one even if its contents are identical. That’s exactly why onEdit is passed down as a stable reference rather than an inline arrow function here, and why the custom comparator on UserList explicitly checks prevProps.onEditUser === nextProps.onEditUser — without a stable callback reference feeding into it, the custom comparator would always see a “changed” prop and memo would provide no benefit at all. The second, easy-to-miss detail: React.memo only wraps the default export it’s applied to — if ExpensiveUserCard itself created new object props for grandchildren internally, those would still need their own memoization; memo doesn’t cascade automatically through a tree.
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),
[]
);
// Note: the empty dependency array here means the debounced function is
// created exactly once and its internal `debounce()` timer/closure is
// never recreated — which is what you actually want for a debouncer, but
// it also means this closes over setSearchTerm at mount time. Since
// setSearchTerm from useState is guaranteed stable across renders, that's
// safe here; if the callback passed to debounce() referenced any other
// state directly (instead of through a setter's updater form), it would
// capture a stale value forever, a common debounce/useCallback pitfall.
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);
};
}
processedUsers and statistics both recompute only when their listed dependencies change, which matters here specifically because OptimizedUserDashboard re-renders on every state change in the component — including, say, sortConfig changing, which has nothing to do with statistics. Without useMemo, statistics would recalculate on every render regardless of whether users or selectedUsers.size actually changed, which is wasted work that scales with list size. The general rule worth internalizing: useMemo/useCallback are a tool for skipping recomputation across unrelated re-renders, not for avoiding computation on the render where the dependency genuinely did change — they can’t make a calculation itself faster, only skip re-running it unnecessarily.
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]);
// This is the crux of the pattern: splitting one context into two
// (UserActionsContext, UserDataContext) means a component that only
// consumes useUserActions() — like AddUserForm below — never re-renders
// when `users`/`loading`/`error` change, because it never subscribed to
// UserDataContext in the first place. A single combined context would
// re-render every consumer on *any* change to *any* field, regardless of
// which fields that particular consumer actually reads — Context
// subscriptions in React are all-or-nothing per Provider, not
// per-field, which is the underlying limitation this split works around.
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. Memoization reduces unnecessary re-renders, but it doesn’t help with the cost of rendering thousands of DOM nodes in the first place — a list of 10,000 rows, even perfectly memoized, still means 10,000 real DOM elements sitting in the document, which is expensive to create, expensive to lay out, and expensive to keep in memory. Virtualization (also called windowing) sidesteps the problem entirely by only ever rendering the handful of rows currently visible in the viewport, swapping their content as the user scrolls — the DOM node count stays roughly constant regardless of whether the underlying list has 100 items or 100,000:
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 — worth noting this is defined *inside*
// VirtualizedUserList's function body, which means a brand-new Row
// component (and a brand-new memo() wrapper around it) is created on
// every render of the parent, defeating memo() entirely: React treats
// each render's Row as a completely different component type from the
// last, so there's no stable identity for it to compare props against.
// In production code, Row should be defined once, outside the component.
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>
);
});
// A worth-flagging inconsistency in this example: getItemSize is defined
// to compute a per-row height, but the List above is FixedSizeList (from
// react-window) with a fixed itemSize={60} — FixedSizeList's itemSize prop
// only accepts a single number, so getItemSize as written here is unused.
// To actually support rows of different heights (some expanded, some not),
// you'd swap the import to react-window's VariableSizeList, whose itemSize
// prop accepts exactly this kind of (index) => number function.
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. Everything up to this point has been about rendering efficiency — doing less work per render — but a large chunk of real-world “React feels slow” complaints are actually about the initial load: shipping a single JavaScript bundle containing every page’s code means users pay the download-and-parse cost for routes they may never visit. React.lazy combined with dynamic import() splits each route into its own chunk that’s only fetched when a user actually navigates there, and Suspense provides the loading state while that chunk downloads — this is a genuinely different optimization axis from memoization, addressing time-to-interactive rather than re-render cost:
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>
);
});
The preload calls here — setTimeout after initial mount, and onMouseEnter on nav links — are both hedges against React.lazy’s one real downside: the first visit to a lazy-loaded route pays a network round-trip before it can render, visible as a loading spinner flash even on a fast connection. Preloading on hover means the chunk is often already downloaded by the time the user actually clicks (a typical hover-before-click gap is a few hundred milliseconds, often enough for a small chunk to finish fetching), and the delayed background preload after mount is a bet that whatever pages are “likely next” are worth fetching speculatively once the critical initial render is out of the way — both are UX polish on top of the core code-splitting mechanism, not code-splitting itself.
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>
);
This extends the same idea below route granularity — splitting individual heavy components (a charting library, a data table, a PDF report generator) that only some users of a given page will ever interact with, rather than splitting only at the page level. The tab-based structure here matters for a subtle reason: because each tab’s component is wrapped in its own Suspense, switching tabs doesn’t re-trigger a full-page loading state — only the newly-selected, not-yet-loaded tab shows its skeleton, while the currently-visible content (if the user switches back) stays mounted and doesn’t re-fetch its chunk, since import() results are cached by the module system after the first load.
Bundle Optimization and Analysis
Webpack Bundle Analysis
Understand and optimize your bundle composition. Code splitting reduces how much JavaScript a given route needs, but it doesn’t guarantee that code is organized efficiently — a vendors bundle mixing a rarely-updated library like React with a frequently-updated one like your own app code means users re-download the entire combined bundle (including React) every time you ship a small app-code change, since it’s all one file with one cache-busting hash. Splitting vendor code from app code, and further splitting large, independently-versioned libraries into their own chunks, lets browsers cache the stable parts long-term and only re-fetch what actually changed:
// 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"
}
}
priority in each cacheGroups entry resolves conflicts when a module could match more than one group — lodash, being inside node_modules, technically matches the generic vendor group too, but its higher priority (20 vs. 10) means it gets pulled into its own dedicated chunk instead of getting bundled into the generic vendors chunk with everything else. webpack-bundle-analyzer is worth running before guessing at any of this configuration, not after — it visualizes exactly which dependencies are taking up space in the actual built output, which is usually more revealing (and often more surprising) than assuming you know where the bloat is coming from.
Tree Shaking Optimization
Ensure unused code is eliminated. Tree shaking is the bundler’s ability to statically detect which exports from a module are actually used and exclude the rest from the final bundle — but it depends entirely on the bundler being able to prove, at build time, that a given export is unreachable, which is a much weaker guarantee than it sounds:
// 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';
The export * from './dateUtils' pattern isn’t necessarily broken for tree shaking with a modern bundler, but it removes a layer of certainty — the bundler has to trace through the re-export to confirm each individual named export is unused, which works reliably with well-behaved ES modules but can fail silently with certain module shapes, especially CommonJS interop. import _ from 'lodash' is the more clear-cut problem: plain lodash ships as CommonJS, and CommonJS’s module.exports is a single dynamic object that bundlers generally can’t statically analyze to prove any individual property is unused — so importing the default export, even to use one function, tends to pull in the whole library (tens of kilobytes) regardless of what you actually call. lodash-es (an ES-module build) or per-function imports sidestep this because each function is its own module with a clear, statically-analyzable export.
Performance Monitoring and Debugging
React DevTools Integration
Implement comprehensive performance monitoring. Everything covered so far is an optimization technique applied on a hunch; the Profiler API is how you replace the hunch with a measurement — it wraps a subtree and reports, per commit, exactly how long React spent rendering it, which is the difference between “I think this component re-renders too much” and “this component re-rendered 40 times in the last 10 seconds, averaging 22ms each.” Optimizing without measuring first risks spending effort memoizing a component that was never actually slow, while missing a genuinely expensive one elsewhere in the tree:
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`);
}
};
// The 16ms threshold isn't arbitrary — it's derived from 1000ms / 60fps,
// the per-frame time budget for a display refreshing at 60Hz. A render
// that takes longer than that can't finish within a single frame, which
// is what actually shows up to a user as jank (a skipped or delayed
// frame) rather than a smooth update. actualDuration vs. baseDuration is
// also worth distinguishing: actualDuration reflects this specific
// render (benefiting from memoization if applicable), while
// baseDuration estimates the cost of rendering the whole subtree from
// scratch with no memoization at all — comparing the two tells you how
// much memoization is actually buying you.
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. These two hooks solve a problem the built-in useCallback/useMemo can’t: React’s dependency-array comparison is always shallow (Object.is per item), so if a dependency is an object or array that gets recreated with equivalent-but-not-identical contents on every render, useCallback/useMemo will recompute every time regardless — they have no way to know the contents didn’t actually change, only that the reference did:
// 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;
}
// useStableCallback and useDeepMemo both work the same way under the hood:
// a ref holds the "last known" callback/dependencies, and a custom
// equality check (reference equality with manual dependency comparison
// for the former, a recursive deepEqual for the latter) decides whether
// to update that ref — sidestepping useCallback/useMemo's built-in
// shallow-only comparison entirely. The real tradeoff to know before
// reaching for useDeepMemo: a deep equality check has to walk the entire
// object on every render to decide whether to skip recomputation, which
// itself has a cost — worth it only when the factory function it's
// guarding is meaningfully more expensive than the deep comparison itself.
// 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 — every technique in this guide has a real cost of its own (a memoized value still needs its dependency array checked every render, a wrapped memo component still needs its props diffed), so applying them uniformly to components that were never actually slow tends to add complexity without measurably improving anything. The Profiler and DevTools coverage above exists specifically so “should I optimize this” stops being a guess.
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.