React Performance Tips: Memoization, Code Splitting, and Lazy Loading
Optimize your React applications. Learn memoization, code splitting, and lazy loading techniques to improve performance and user experience.
A fast React app feels effortless: pages appear quickly, typing never stutters, and scrolling stays smooth. A slow one makes users doubt your whole product, even when the features are great. The good news is that most React performance problems come from a small set of causes, each with a well-understood fix: memoization, code splitting, lazy loading, list virtualization, and measurement. This guide covers the reasoning behind each so you know when to reach for which.
There are really two kinds of "slow" in a React app. Slow to load means shipping too much JavaScript before the page becomes usable; code splitting and lazy loading attack that. Slow to interact means the app is loaded but re-rendering too much work on every keystroke or click; memoization and virtualization attack that. Diagnose which one you have before touching any code, because the fixes don't overlap.
Measure before you optimize
The first rule of performance work: never guess. React DevTools has a Profiler tab that records exactly which components rendered during an interaction and how long each took. Record a suspicious click or keystroke and look for two smells: components that render when their data didn't change, and single components that take many milliseconds per render. Chrome DevTools' Performance panel and Lighthouse cover the loading side.
Optimizing without measuring usually means memoizing components that were never the problem, adding complexity for nothing. Measure, fix the biggest item, measure again.
Why React re-renders (and why that's usually fine)
When a component's state changes, React re-renders that component and, by default, its entire subtree. That sounds wasteful, but re-rendering is usually cheap: React only touches the real DOM where the output actually changed. Re-renders become a problem when the subtree is large, when individual components do expensive work while rendering, or when renders happen very frequently (typing, scrolling, animations). Every technique in the next three sections exists to break one of those conditions.
React.memo: skip re-renders when props haven't changed
React.memo wraps a component and skips re-rendering it when its props are shallowly equal to last time:
// Without memo: re-renders whenever the parent renders
function UserCard({ user, onDelete }) {
return (
<div>
<h2>{user.name}</h2>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
);
}
// With memo: only re-renders when props actually change
const UserCard = React.memo(({ user, onDelete }) => {
return (
<div>
<h2>{user.name}</h2>
<button onClick={() => onDelete(user.id)}>Delete</button>
</div>
);
});
The catch is the phrase "shallowly equal." Props are compared by reference, so if the parent passes an inline object (style={{ color: 'red' }}) or an inline arrow function, that prop is a brand-new reference every render and the memo never skips anything. You silently pay the comparison cost with none of the benefit. This is exactly why useMemo and useCallback exist: they keep references stable so React.memo can do its job.
Use React.memo when a component is expensive to render, receives the same props most of the time, and profiling shows it re-rendering needlessly. Don't wrap everything by default; each memoized component adds a comparison on every render and another thing to reason about.
useMemo: cache expensive computations
useMemo remembers the result of a calculation and only recomputes it when its dependencies change:
function Dashboard({ users }) {
// Naive version recalculates on every render:
// const activeUsers = users.filter(u => u.active);
// const totalSpend = activeUsers.reduce((sum, u) => sum + u.spend, 0);
// With useMemo, each value only recalculates when its deps change:
const activeUsers = useMemo(
() => users.filter(u => u.active),
[users]
);
const totalSpend = useMemo(
() => activeUsers.reduce((sum, u) => sum + u.spend, 0),
[activeUsers]
);
return <div>Total: ${totalSpend}</div>;
}
Two honest caveats. First, filtering a few hundred items is fast; useMemo earns its keep on genuinely heavy work (sorting large arrays, deriving chart data) or when the result is passed as a prop to a memoized child and needs a stable reference. Second, the dependency array is a contract: list everything the calculation reads, or you'll serve stale values that are miserable to debug.
useCallback: stable function references
useCallback is useMemo for functions. Its main job is keeping callbacks referentially stable so memoized children don't re-render:
function ParentComponent({ children }) {
const [count, setCount] = useState(0);
// Without useCallback: a new function identity on every render,
// which defeats React.memo on any child receiving it
// const handleDelete = (id) => deleteUser(id);
// With useCallback: the same function until deps change
const handleDelete = useCallback((id) => {
deleteUser(id);
}, []);
// Now safe to pass to a memoized child
return <MemoizedChild onDelete={handleDelete} />;
}
The pattern to remember: React.memo on the child, useCallback on the handlers you pass it, useMemo on the objects and arrays you pass it. Any one of them alone often does nothing.
One more thing worth knowing in 2026: the React Compiler can apply this kind of memoization automatically at build time, so projects that adopt it write far fewer manual useMemo/useCallback calls. Understanding the mechanics still matters, both for the many codebases that haven't adopted it and for debugging when memoization misbehaves.
Code splitting: ship less JavaScript up front
By default, bundlers put your whole app into the JavaScript users download before anything becomes interactive. Code splitting breaks that bundle into chunks loaded on demand:
// Without code splitting: HeavyChart ships in the main bundle
import HeavyChart from './components/HeavyChart';
function Dashboard() {
return <HeavyChart />;
}
// With code splitting: the chart loads only when rendered
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
);
}
lazy turns the import into a separate chunk, and Suspense tells React what to show while that chunk downloads. Always provide a meaningful fallback; a blank region reads as "broken" to users.
The highest-leverage form is route-based splitting, because users only visit a few pages per session and shouldn't pay for the rest:
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Admin = lazy(() => import('./pages/Admin'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />
</Routes>
</Suspense>
);
}
An admin panel only a few users ever open has no business in everyone's initial download. Next.js does route splitting automatically, one reason it's a shortcut to good baseline performance.
Lazy load images
Images often outweigh your JavaScript entirely, and the browser can defer offscreen ones natively:
function ImageGallery() {
return (
<div>
<img
src="thumb.jpg"
loading="lazy" // Browser-native lazy loading
width="400"
height="300"
alt="Gallery item"
/>
</div>
);
}
Two details matter here. loading="lazy" should only go on images below the fold; lazy-loading your hero image delays the thing users came to see. And always set width and height (or reserve space with CSS) so the page doesn't jump when images arrive; that jumping is layout shift, and it's measured against you by Core Web Vitals.
Next.js users get compression, resizing, blur placeholders, and lazy loading in one component:
import Image from 'next/image';
export default function Gallery() {
return (
<Image
src="/photo.jpg"
width={800}
height={600}
loading="lazy" // Lazy load below-the-fold images
quality={80} // Compress
placeholder="blur" // Show a blur while loading
alt="Gallery photo"
/>
);
}
Virtualize long lists
Rendering ten thousand DOM nodes will make any framework crawl, because the browser has to lay out and paint all of them. Virtualization renders only the rows currently visible in the viewport, plus a small buffer:
// Without virtualization: renders all 10,000 items
function LargeList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// With virtualization: renders only the visible rows
import { FixedSizeList } from 'react-window';
function LargeList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={35}
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
);
}
With a 600px viewport and 35px rows, that's roughly 20 rendered rows instead of 10,000, regardless of how long the list grows. Reach for this whenever a list can realistically exceed a few hundred items: tables, feeds, log viewers, autocomplete results.
Keep state close to where it's used
Where you put state determines how much re-renders when it changes. A classic example is a form object held in one useState at the top of a large component tree: every keystroke in any field re-renders everything reading that object.
// Problem: one keystroke re-renders every consumer of `form`
const [form, setForm] = useState({
name: '',
email: '',
password: ''
});
// Often better: independent state, independent re-renders
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
The principle is called state colocation: keep state in the smallest component that needs it, and lift it up only when something else genuinely has to share it. The same logic applies to context: a single giant context re-renders every consumer whenever any value inside it changes, so split contexts by how often their values change.
Find out what's actually in your bundle
Before optimizing your bundle, look inside it:
npm install -D @next/bundle-analyzer
# In next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer(nextConfig);
# Run
ANALYZE=true npm run build
The analyzer draws a treemap of every dependency by size. The usual finds: a date library shipping every locale, a charting library imported on every page but used on one, or two libraries doing the same job. Each of those is a bigger win than any amount of memoization.
Monitor what users actually experience
Lab tests run on your fast machine; your users have older phones and spottier networks. Core Web Vitals are the field metrics worth watching:
| Metric | Measures | Good threshold |
|---|---|---|
| LCP (Largest Contentful Paint) | How fast the main content appears | Under 2.5s |
| INP (Interaction to Next Paint) | How fast the UI responds to input | Under 200ms |
| CLS (Cumulative Layout Shift) | How much the page jumps around | Under 0.1 |
Collecting them from real sessions takes a few lines:
// Monitor Core Web Vitals (web-vitals v3+)
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
navigator.sendBeacon(
'/analytics',
JSON.stringify({ name: metric.name, value: metric.value })
);
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Notice how the metrics map onto this article: code splitting and image handling improve LCP, memoization and virtualization improve INP, and reserving space for images protects CLS.
Common mistakes
- Optimizing before measuring. You'll fix the wrong thing and complicate the code doing it.
- Memoizing everything. Comparisons aren't free, and inline object props silently defeat
React.memoanyway. - Ignoring bundle size while tuning renders. If users wait seconds for JavaScript to download, render tuning is rearranging deck chairs.
- Lazy loading above-the-fold content. Deferring your hero image or main route makes the first paint worse, not better.
- No loading states. Every
lazyboundary and data fetch needs a fallback, or users assume the app broke. - Testing only on fast hardware. Throttle CPU and network in DevTools; that's much closer to your median user.
Frequently asked questions
Should I memoize every component by default?
No. Memoize when profiling shows a component re-rendering often with unchanged props and the render is expensive. Blanket memoization adds comparison overhead and cognitive load for wins that mostly don't exist.
Does the React Compiler make useMemo and useCallback obsolete?
Increasingly, yes, in codebases that adopt it: the compiler inserts equivalent memoization automatically. But plenty of production apps haven't migrated, interview questions still cover the manual hooks, and understanding referential equality remains essential for debugging either way.
What's the difference between React.memo and useMemo?
React.memo wraps a component and skips its re-render when props are unchanged. useMemo caches a value inside a component between renders. They solve related problems and often work together: useMemo keeps a prop's reference stable so React.memo can detect it as unchanged.
How do I find out why a component keeps re-rendering?
Open React DevTools, enable "Highlight updates when components render" for a quick visual, then use the Profiler with "Record why each component rendered" turned on. It tells you exactly which prop, state, or hook change triggered each render.
Keep learning React
Performance work is much easier when the fundamentals are solid, because every technique here builds on how rendering, props, and state actually work. Kodion's interactive React Essentials course takes you through components, hooks, and rendering behavior with exercises you complete right in the browser, so the mental model behind memo, lazy, and friends becomes second nature.
Kodion Team
Kodion Editorial
Written by the team that builds Kodion's courses: working engineers who test every example in the interactive editor before it ships. How we create and review content
Continue Learning
📚 Related Articles
Database Optimization: Indexing Strategies for High-Traffic Apps
Master database indexing and optimization techniques. Learn how to dramatically improve query performance for applications handling millions of requests.
The JavaScript Array Methods You Actually Use: map, filter, reduce and Friends
Master map, filter, find, some, every, and reduce with real examples, plus the mutation traps and modern ES2023 methods that keep your code clean.