React Performance: Memoization and Code Splitting
React's rendering model is simple: when state or props change, the component re-renders. The problem? React's default behavior is aggressive. When a parent component re-renders, all its children...
Key Insights
- React re-renders components whenever parent state changes, even if the component’s own props haven’t changed—memoization techniques like
React.memo(),useMemo(), anduseCallback()prevent these unnecessary re-renders by caching values and components based on dependency changes. - Code splitting with
React.lazy()and dynamic imports reduces initial bundle size by loading components only when needed, cutting first-load time by 40-60% in typical applications with proper route-based splitting. - Premature optimization wastes time—profile with React DevTools first to identify actual bottlenecks, then apply memoization to components that render frequently with stable props and code splitting to routes or features users may never access.
Understanding React Re-renders
React’s rendering model is simple: when state or props change, the component re-renders. The problem? React’s default behavior is aggressive. When a parent component re-renders, all its children re-render too, regardless of whether their props actually changed.
Here’s a concrete example of the problem:
function ParentComponent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
<ExpensiveChild data="static data" />
</div>
);
}
function ExpensiveChild({ data }) {
console.log('ExpensiveChild rendered');
// Simulate expensive computation
const result = Array.from({ length: 10000 }, (_, i) => i * 2);
return <div>{data}</div>;
}
Every time you click the button, ExpensiveChild re-renders and logs to the console, even though its data prop never changes. This is wasted work. The virtual DOM diffing process still happens, and for complex components, this adds up quickly.
Memoization Techniques
React.memo() for Component Memoization
React.memo() is a higher-order component that memoizes the entire component based on props. It performs a shallow comparison of props and skips rendering if they haven’t changed.
const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
console.log('ExpensiveChild rendered');
const result = Array.from({ length: 10000 }, (_, i) => i * 2);
return <div>{data}</div>;
});
Now ExpensiveChild only re-renders when data actually changes. For list items, this is transformative:
// Without memo - every item re-renders when any item changes
function TodoItem({ todo, onToggle }) {
console.log(`Rendering todo ${todo.id}`);
return (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
);
}
// With memo - only the changed item re-renders
const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
console.log(`Rendering todo ${todo.id}`);
return (
<li onClick={() => onToggle(todo.id)}>
{todo.text}
</li>
);
});
In a list of 100 items, this means 1 re-render instead of 100 when toggling a single todo.
useMemo() for Expensive Calculations
useMemo() caches the result of expensive computations. It only recalculates when dependencies change.
function ProductList({ products, searchTerm, sortBy }) {
// Without useMemo - filters and sorts on every render
// const filteredProducts = products
// .filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
// .sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
// With useMemo - only recalculates when products, searchTerm, or sortBy change
const filteredProducts = useMemo(() => {
console.log('Filtering and sorting products');
return products
.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1);
}, [products, searchTerm, sortBy]);
return (
<ul>
{filteredProducts.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
This matters when products is an array of 1000+ items. Without useMemo(), unrelated state changes (like a loading indicator) would trigger the expensive filter/sort operation.
useCallback() for Stable Function References
useCallback() solves a specific problem: functions defined in components are recreated on every render, breaking referential equality. This causes memoized child components to re-render unnecessarily.
function TodoList({ todos }) {
const [filter, setFilter] = useState('all');
// Without useCallback - new function every render
// const handleToggle = (id) => {
// // toggle logic
// };
// With useCallback - same function reference unless dependencies change
const handleToggle = useCallback((id) => {
console.log('Toggling todo', id);
// toggle logic
}, []); // Empty deps - function never changes
return (
<div>
<FilterButtons filter={filter} setFilter={setFilter} />
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
/>
))}
</div>
);
}
Without useCallback(), every TodoItem re-renders when filter changes because handleToggle is a new function reference. With useCallback(), TodoItem (wrapped in React.memo()) correctly skips re-rendering.
Code Splitting Fundamentals
Code splitting addresses a different problem: bundle size. A typical React app bundles all JavaScript into one file. Users download code for features they never use—admin panels, settings pages, rich text editors.
React.lazy() and Suspense enable loading components on demand:
import { lazy, Suspense } from 'react';
// Instead of: import HeavyChart from './HeavyChart';
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Analytics
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
The chart library (often 50-100KB) only loads when the user clicks the button. Initial page load is faster, and users who never view analytics never download that code.
Advanced Code Splitting Patterns
Route-Based Splitting
The highest-impact code splitting strategy is splitting by route. Most users don’t visit every page in a session.
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
This typically reduces initial bundle size by 40-60%. Users loading the homepage don’t download the admin panel code.
Component-Based Splitting
Split heavy, conditionally-rendered components:
import { lazy, Suspense, useState } from 'react';
const RichTextEditor = lazy(() => import('./RichTextEditor'));
const ImageEditor = lazy(() => import('./ImageEditor'));
function ContentCreator() {
const [editorType, setEditorType] = useState(null);
return (
<div>
<button onClick={() => setEditorType('text')}>
Write Article
</button>
<button onClick={() => setEditorType('image')}>
Edit Image
</button>
<Suspense fallback={<LoadingSpinner />}>
{editorType === 'text' && <RichTextEditor />}
{editorType === 'image' && <ImageEditor />}
</Suspense>
</div>
);
}
Rich text editors and image manipulation libraries are massive. Loading them only when needed keeps the initial bundle lean.
Measuring Performance Impact
Don’t guess—measure. Use React DevTools Profiler to verify optimizations work.
import { Profiler } from 'react';
function onRenderCallback(
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<YourComponent />
</Profiler>
);
}
In DevTools Profiler, record a session, interact with your app, then review the flame graph. Look for:
- Components rendering frequently with no prop changes (candidates for
React.memo()) - Long render times in components doing heavy calculations (candidates for
useMemo()) - Large bundle sizes in the Network tab (candidates for code splitting)
Compare metrics before and after optimization. A successful memoization might reduce render time from 45ms to 2ms. Effective code splitting might cut initial bundle from 800KB to 300KB.
Best Practices and Common Pitfalls
Don’t optimize prematurely. Memoization adds complexity. Start with clean, simple code. Profile to find actual bottlenecks, then optimize.
Memoization isn’t free. React.memo() performs shallow prop comparisons on every render. For simple components that render quickly, this overhead exceeds the benefit. Only memoize components that are expensive to render or render frequently.
Beware of dependencies. The most common mistake with useMemo() and useCallback() is incorrect dependencies. Missing dependencies cause stale closures. Unnecessary dependencies cause cache invalidation, defeating the purpose.
Code split at route boundaries first. This gives maximum benefit with minimal complexity. Component-level splitting requires careful UX consideration—users notice loading spinners.
Use custom comparisons when needed. React.memo() accepts a custom comparison function for complex prop structures:
const MyComponent = React.memo(
function MyComponent({ data }) {
return <div>{data.value}</div>;
},
(prevProps, nextProps) => {
return prevProps.data.id === nextProps.data.id;
}
);
Combine techniques strategically. Use React.memo() on child components, useCallback() for their callbacks, and useMemo() for expensive props. Code split routes and heavy features. Together, these techniques keep apps fast as they scale.
Performance optimization is about trade-offs. These techniques add complexity. Use them where they matter—high-traffic pages, large lists, expensive computations, and heavy dependencies. For everything else, keep it simple.