React Performance Optimization Tips
React is fast by default — but as your application grows in complexity, subtle inefficiencies can accumulate into noticeable sluggishness. Components re-rendering unnecessarily, expensive computations running on every keystroke, and huge JavaScript bundles blocking the main thread are all common culprits. The good news: React provides a rich set of tools to diagnose and fix these bottlenecks. This guide walks you through the most impactful React performance optimization techniques, with real-world examples you can apply immediately.
Never optimize blindly. Use the React DevTools Profiler and Chrome's Performance panel to identify actual bottlenecks first. Premature optimization wastes time and introduces unnecessary complexity. Profile, identify the slowest components, then fix them.
Understanding React's Rendering Behavior
Before diving into optimizations, you must understand why React re-renders components. React re-renders a component whenever:
- Its state changes — via
useStateoruseReducer - Its props change — any prop receiving a new reference or value
- Its parent re-renders — even if the component's own props haven't changed
- A context it subscribes to updates — all consumers re-render when context value changes
Re-rendering isn't always expensive — React's virtual DOM diffing is fast. The problem arises when re-renders trigger expensive computations, cause child subtrees to re-render unnecessarily, or happen at very high frequency (e.g., on every keystroke or mouse move).
React's Render → Commit → Paint cycle, and where memoization creates a bailout
Memoization: React.memo, useMemo, useCallback
Memoization is the practice of caching a result so it doesn't need to be recalculated. React offers three memoization primitives — each with a distinct purpose.
React.memo — Prevent Unnecessary Component Re-renders
React.memo is a higher-order component that wraps a functional component and skips re-rendering if its props haven't changed (shallow comparison). It's useful when a child component receives the same props on every parent re-render.
// Without memo — re-renders every time parent re-renders
function ExpensiveChart({ data }) {
console.log('Chart rendered');
return <div>{/* heavy chart rendering */}</div>;
}
// With memo — only re-renders when `data` prop changes
const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
console.log('Chart rendered');
return <div>{/* heavy chart rendering */}</div>;
});
// Custom comparison function for deep equality
const ExpensiveChart = React.memo(
function ExpensiveChart({ data, config }) {
return <div>{/* ... */}</div>;
},
(prevProps, nextProps) => {
// Return true to SKIP re-render (props are "equal")
return (
prevProps.data.id === nextProps.data.id &&
prevProps.config.theme === nextProps.config.theme
);
}
);
React.memo uses shallow comparison. If you pass an object literal style={{ color: 'red' }} or inline function onClick={() => doSomething()} as a prop, a new reference is created on every render, defeating memo. Always use useMemo or useCallback to stabilize those references.
useCallback — Stabilize Function References
useCallback returns a memoized version of a callback function that only changes when one of its dependencies changes. Without it, a new function reference is created on every render, causing React.memo children to re-render anyway.
import { useState, useCallback } from 'react';
function ParentComponent() {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// BAD: new function reference on every render
// const handleDelete = (id) => deleteItem(id);
// GOOD: stable reference, only changes if deleteItem changes
const handleDelete = useCallback((id) => {
deleteItem(id);
}, []); // empty deps = never changes
// With dependencies
const handleSearch = useCallback((query) => {
fetchResults(query, { userId: currentUser.id });
}, [currentUser.id]); // re-creates only when user changes
return (
<>
<input value={text} onChange={e => setText(e.target.value)} />
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
{/* ItemList won't re-render when count or text changes */}
<ItemList onDelete={handleDelete} />
</>
);
}
const ItemList = React.memo(({ onDelete }) => {
console.log('ItemList rendered');
return <ul>{/* items */}</ul>;
});
useMemo — Cache Expensive Computations
useMemo memoizes the result of a computation. Use it when a derived value is expensive to calculate and its dependencies change infrequently.
import { useMemo, useState } from 'react';
function ProductList({ products, filters }) {
const [sortBy, setSortBy] = useState('price');
// BAD: recalculates on every render — even on unrelated state changes
// const filteredProducts = products
// .filter(p => p.category === filters.category)
// .sort((a, b) => a[sortBy] - b[sortBy]);
// GOOD: only recalculates when products, filters, or sortBy changes
const filteredProducts = useMemo(() => {
console.log('Filtering and sorting products...');
return products
.filter(p => p.category === filters.category && p.price <= filters.maxPrice)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price;
if (sortBy === 'name') return a.name.localeCompare(b.name);
return b.rating - a.rating;
});
}, [products, filters.category, filters.maxPrice, sortBy]);
return (
<div>
<select value={sortBy} onChange={e => setSortBy(e.target.value)}>
<option value="price">Price</option>
<option value="name">Name</option>
<option value="rating">Rating</option>
</select>
{filteredProducts.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
| Hook / API | What It Memoizes | When to Use |
|---|---|---|
React.memo |
Entire component render output | Child component with stable props that re-renders due to parent |
useCallback |
Function reference | Callbacks passed as props to memoized children or used in useEffect deps |
useMemo |
Computed value | Expensive calculations (filtering, sorting, mapping large arrays) |
Code Splitting and Lazy Loading
Shipping one giant JavaScript bundle is one of the most common performance killers. Code splitting lets you split your app into smaller chunks that are loaded on demand, dramatically reducing your initial page load time.
React.lazy and Suspense
React's built-in React.lazy enables component-level code splitting. When the lazy component is first rendered, React downloads its bundle asynchronously. Suspense provides a loading fallback in the meantime.
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
// Each page becomes a separate bundle loaded on demand
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
);
}
// You can also nest Suspense for more granular control
function Dashboard() {
const HeavyChart = lazy(() => import('../components/HeavyChart'));
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart />
</Suspense>
</div>
);
}
Dynamic Imports for Non-Component Code
// Don't import heavy libraries at the top of the file
// import { PDFDocument } from 'pdf-lib'; // ~400KB
async function handleExportPDF(data) {
// Only loads pdf-lib when the user actually clicks "Export PDF"
const { PDFDocument } = await import('pdf-lib');
const pdfDoc = await PDFDocument.create();
// ... generate PDF
}
// Same pattern works for chart libraries, parsers, etc.
async function renderChart(canvas, data) {
const { Chart } = await import('chart.js/auto');
new Chart(canvas, { type: 'line', data });
}
Virtualization: Rendering Large Lists
Rendering thousands of DOM nodes is expensive regardless of how fast your JavaScript is. If your list has more than ~100 items visible at once, virtualization is the single biggest performance win available. The idea: only render items currently in the viewport.
Install a virtualization library
The most popular options are react-window (lightweight) and react-virtual (TanStack Virtual — headless, flexible). For most use cases, react-window is the easiest starting point.
Replace your map with a virtualized list
Wrap your list in FixedSizeList (uniform row heights) or VariableSizeList (dynamic heights). Only visible rows are mounted in the DOM.
import { FixedSizeList } from 'react-window';
const ITEMS = Array.from({ length: 100_000 }, (_, i) => ({
id: i,
name: `User ${i}`,
email: `user${i}@example.com`,
}));
// Row component — receives index and style from react-window
const Row = React.memo(({ index, style }) => (
<div style={style} className="list-row">
<span>{ITEMS[index].name}</span>
<span>{ITEMS[index].email}</span>
</div>
));
function UserList() {
return (
<FixedSizeList
height={600} // container height in px
itemCount={ITEMS.length}
itemSize={56} // row height in px
width="100%"
>
{Row}
</FixedSizeList>
);
}
// Result: ~10 DOM nodes in the list at any time
// instead of 100,000 — massive memory and paint savings
State Management Performance
Poor state placement is the number one cause of unnecessary re-renders in real-world React apps. The goal is to keep state as local as possible and avoid triggering large subtree re-renders.
Collocate State — Don't Lift Unnecessarily
// BAD: search state in a top-level component
// causes entire App to re-render on every keystroke
function App() {
const [search, setSearch] = useState('');
return (
<div>
<Header /> {/* re-renders unnecessarily */}
<Sidebar /> {/* re-renders unnecessarily */}
<SearchBar value={search} onChange={setSearch} />
<Results query={search} />
</div>
);
}
// GOOD: search state lives in the component that needs it
function SearchSection() {
const [search, setSearch] = useState('');
return (
<div>
<SearchBar value={search} onChange={setSearch} />
<Results query={search} />
</div>
);
}
function App() {
return (
<div>
<Header /> {/* never re-renders due to search */}
<Sidebar /> {/* never re-renders due to search */}
<SearchSection />
</div>
);
}
Context Optimization — Split Contexts by Update Frequency
A common mistake is putting both frequently-changing values (like current user's notifications) and rarely-changing values (like theme) in a single context. Every context update re-renders all consumers.
// BAD: one context for everything
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('dark');
const [notifications, setNotifications] = useState([]);
// Any notification causes theme consumers to re-render!
return (
<AppContext.Provider value={{ user, theme, notifications, setNotifications }}>
{children}
</AppContext.Provider>
);
}
// GOOD: separate contexts by how often they change
const ThemeContext = createContext(); // changes rarely
const UserContext = createContext(); // changes on login/logout
const NotificationContext = createContext(); // changes frequently
function AppProvider({ children }) {
const [theme, setTheme] = useState('dark');
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
<NotificationContext.Provider value={{ notifications, setNotifications }}>
{children}
</NotificationContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
useReducer for Complex State Transitions
When state transitions are complex or interdependent, useReducer can be both cleaner and faster than multiple useState calls. The reducer is pure, making it easy to test, and React batches the dispatch calls.
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.item.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.item.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return { ...state, items: [...state.items, { ...action.item, quantity: 1 }] };
}
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case 'CLEAR_CART':
return { ...state, items: [] };
default:
return state;
}
};
function Cart() {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
const addItem = useCallback((item) => dispatch({ type: 'ADD_ITEM', item }), []);
const removeItem = useCallback((id) => dispatch({ type: 'REMOVE_ITEM', id }), []);
const total = useMemo(
() => cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
[cart.items]
);
return <div>{/* render */}</div>;
}
Profiling and Measuring Performance
React DevTools ships with a built-in Profiler that lets you record rendering sessions and identify which components are taking the most time. Here's how to use it effectively:
Install React DevTools
Install the React Developer Tools browser extension for Chrome or Firefox. Open DevTools, navigate to the "Profiler" tab.
Record a session
Click the record button, interact with the slow part of your app, then stop recording. The flame chart shows each component and how long it took to render.
Identify re-render causes
Click on a component in the flame chart. The panel shows "Why did this render?" — listing which props, state, or hooks changed. This tells you exactly what to memoize.
Use the Profiler API in code
Wrap components in React's <Profiler> component to log render timings programmatically — useful for production performance monitoring.
import { Profiler } from 'react';
function onRenderCallback(
id, // the "id" prop of the Profiler tree that committed
phase, // "mount" or "update"
actualDuration, // time spent rendering the committed update
baseDuration, // estimated time to re-render without memoization
startTime,
commitTime
) {
if (actualDuration > 16) {
// Log slow renders (16ms = 60fps budget)
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms`);
// Send to your monitoring service
analytics.track('slow_render', { component: id, duration: actualDuration });
}
}
function App() {
return (
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList />
</Profiler>
);
}
Image and Asset Optimization
JavaScript isn't the only bottleneck. Images are often the heaviest assets on a page. A few targeted optimizations can make a dramatic difference:
- Use modern formats: WebP and AVIF offer 25–50% smaller file sizes than JPEG/PNG with equivalent quality
- Lazy load images below the fold: Use
loading="lazy"on<img>tags or theIntersection ObserverAPI for custom control - Serve correctly sized images: Use
srcsetandsizesto serve different resolutions for different screen sizes - Use a CDN: Serve static assets from a CDN close to your users to reduce latency
function OptimizedImage({ src, alt, width, height, priority = false }) {
return (
<img
src={src}
alt={alt}
width={width}
height={height}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
// Prevent layout shift by reserving space
style={{ aspectRatio: `${width} / ${height}` }}
/>
);
}
// For Next.js, use the built-in Image component which handles all of this
import Image from 'next/image';
function Hero() {
return (
<Image
src="/hero.webp"
alt="Hero image"
width={1200}
height={630}
priority // LCP image — load eagerly
quality={85}
/>
);
}
Summary and Key Takeaways
React performance optimization is about eliminating wasted work at every layer of the stack. Here's a prioritized checklist:
Performance Optimization Checklist
- Profile first — use React DevTools Profiler to find actual bottlenecks before optimizing
- Colocate state — keep state as close to where it's used as possible to limit re-render scope
- Memoize selectively — use
React.memo,useCallback, anduseMemowhere profiling shows unnecessary re-renders - Code split by route — use
React.lazy+Suspenseso users only download code they need - Virtualize long lists — never render more DOM nodes than are visible; use
react-windowor TanStack Virtual - Split contexts — separate high-frequency from low-frequency context values to avoid broad re-renders
- Optimize images — use WebP/AVIF, lazy loading, and correct sizing
- Measure in production — use the Profiler API and Real User Monitoring (RUM) for real-world data
Don't add useMemo, useCallback, or React.memo everywhere "just in case." Each one adds overhead (memory for the cache, time for the comparison). Only apply them where profiling shows a real problem. Premature optimization is the root of all evil — even in React.
"The fastest code is the code that doesn't run at all. In React, the fastest render is the render that never happens."
— A core principle of React optimization
By understanding React's rendering model and applying these techniques deliberately, you can build applications that feel instant even as they grow in complexity. Start with profiling, fix the biggest bottleneck, measure again, and repeat. Performance is a feature — and it's one your users will notice.
