React Performance Optimization
A practical guide to making React apps fast: memoization, virtualization, code splitting, the Profiler, and the patterns that actually matter in production.
What you'll learn
- ✓When and how to use React.memo, useMemo, and useCallback
- ✓List virtualization for large datasets
- ✓Code splitting with lazy and Suspense
- ✓How to use the React Profiler to find bottlenecks
- ✓Common performance pitfalls and how to avoid them
Prerequisites
- •Comfortable with React hooks and component lifecycle
- •Basic understanding of rendering and reconciliation
React is fast by default for most apps. The problems show up when components re-render too often, render too much DOM, or ship too much JavaScript. This guide covers the tools and patterns that fix those problems, in the order you should reach for them.
How React rendering works
Before optimizing, understand what triggers a render. A component re-renders when:
- Its state changes (via
useState,useReducer) - Its parent re-renders (props may or may not have changed)
- A context it consumes changes
A render does not mean the DOM updates. React diffs the virtual DOM against the previous render and only touches the real DOM where things changed. The cost you are optimizing is the JavaScript execution of the render function itself and the diffing process.
React.memo
React.memo wraps a component and skips re-rendering if its props have not changed (shallow comparison by default).
import { memo } from "react";
interface UserCardProps {
name: string;
email: string;
role: string;
}
const UserCard = memo(function UserCard({ name, email, role }: UserCardProps) {
console.log("UserCard rendered");
return (
<div className="user-card">
<h3>{name}</h3>
<p>{email}</p>
<span className="badge">{role}</span>
</div>
);
});
When to use memo:
- The component renders often but its props rarely change
- The component is expensive to render (complex calculations, deep trees)
- It sits under a parent that re-renders frequently
When not to bother:
- The component is cheap to render
- Props change on every render anyway
- You have not measured and confirmed it is a bottleneck
You can also pass a custom comparison function as the second argument.
const ExpensiveList = memo(
function ExpensiveList({ items, filter }: Props) {
const filtered = items.filter(filter);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
},
(prevProps, nextProps) => {
// Only re-render if items array identity changed
return prevProps.items === nextProps.items;
}
);
useMemo
useMemo caches the result of an expensive computation between renders.
import { useMemo } from "react";
function AnalyticsDashboard({ transactions }: { transactions: Transaction[] }) {
const stats = useMemo(() => {
console.log("Computing stats...");
return {
total: transactions.reduce((sum, t) => sum + t.amount, 0),
average: transactions.reduce((sum, t) => sum + t.amount, 0) / transactions.length,
max: Math.max(...transactions.map((t) => t.amount)),
byCategory: transactions.reduce((acc, t) => {
acc[t.category] = (acc[t.category] || 0) + t.amount;
return acc;
}, {} as Record<string, number>),
};
}, [transactions]);
return (
<div>
<p>Total: ${stats.total.toFixed(2)}</p>
<p>Average: ${stats.average.toFixed(2)}</p>
<p>Max: ${stats.max.toFixed(2)}</p>
{Object.entries(stats.byCategory).map(([cat, amount]) => (
<p key={cat}>{cat}: ${amount.toFixed(2)}</p>
))}
</div>
);
}
useMemo also stabilizes object and array references, which matters when passing them to memoized children.
function ParentComponent({ userId }: { userId: string }) {
const [count, setCount] = useState(0);
// Without useMemo, this creates a new object every render,
// breaking React.memo on ChildComponent
const config = useMemo(
() => ({
userId,
theme: "dark",
showDetails: true,
}),
[userId]
);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<MemoizedChild config={config} />
</div>
);
}
useCallback
useCallback is useMemo for functions. It returns the same function reference between renders unless its dependencies change.
import { useCallback, useState, memo } from "react";
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = useCallback((text: string) => {
setTodos((prev) => [...prev, { id: crypto.randomUUID(), text, done: false }]);
}, []);
const toggleTodo = useCallback((id: string) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}, []);
const deleteTodo = useCallback((id: string) => {
setTodos((prev) => prev.filter((t) => t.id !== id));
}, []);
return (
<div>
<AddTodoForm onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
}
const TodoList = memo(function TodoList({
todos,
onToggle,
onDelete,
}: {
todos: Todo[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
});
The pattern: useCallback for handler functions, memo on the child that receives them. One without the other does nothing useful.
List virtualization
When you have hundreds or thousands of items, rendering all of them is wasteful. Virtualization renders only the items currently visible in the viewport.
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function VirtualizedList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // estimated row height in px
overscan: 5, // render 5 extra items above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: "500px", overflow: "auto" }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="p-3 border-b">
<h4>{item.title}</h4>
<p className="text-sm text-gray-500">{item.description}</p>
</div>
</div>
);
})}
</div>
</div>
);
}
For a list of 10,000 items with a 500px viewport and 60px row height, virtualization renders about 14 DOM nodes instead of 10,000. The difference in initial render time is dramatic.
Code splitting with lazy and Suspense
Ship less JavaScript upfront by splitting your bundle at route or component boundaries.
import { lazy, Suspense, useState } from "react";
// These components are loaded on demand
const AdminPanel = lazy(() => import("./AdminPanel"));
const Analytics = lazy(() => import("./Analytics"));
const Settings = lazy(() => import("./Settings"));
function App() {
const [tab, setTab] = useState("admin");
return (
<div>
<nav>
<button onClick={() => setTab("admin")}>Admin</button>
<button onClick={() => setTab("analytics")}>Analytics</button>
<button onClick={() => setTab("settings")}>Settings</button>
</nav>
<Suspense fallback={<div className="spinner">Loading...</div>}>
{tab === "admin" && <AdminPanel />}
{tab === "analytics" && <Analytics />}
{tab === "settings" && <Settings />}
</Suspense>
</div>
);
}
For route-level splitting with React Router:
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
const Home = lazy(() => import("./pages/Home"));
const Products = lazy(() => import("./pages/Products"));
const Checkout = lazy(() => import("./pages/Checkout"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/checkout" element={<Checkout />} />
</Routes>
</Suspense>
);
}
Using the React Profiler
The Profiler tells you exactly which components rendered, how long they took, and why. Use the React DevTools Profiler tab or the programmatic <Profiler> component.
import { Profiler, ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
if (actualDuration > 16) {
console.warn(
`Slow render: ${id} took ${actualDuration.toFixed(1)}ms (${phase})`
);
}
};
function App() {
return (
<Profiler id="App" onRender={onRender}>
<Dashboard />
</Profiler>
);
}
In DevTools, record a profiling session, interact with your app, then examine the flame graph. Look for:
- Components that render when they should not
- Components with high “self time”
- Large gaps between
baseDurationandactualDuration(indicates memoization is helping)
State management patterns
Where you put state affects what re-renders. Lifting state up causes more components to re-render. Pushing state down limits the blast radius.
// Bad: search state lives too high, re-renders the entire list
function Page() {
const [search, setSearch] = useState("");
const [items, setItems] = useState(initialItems);
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<ExpensiveItemList items={items} />
</div>
);
}
// Good: extract the search input into its own component
function Page() {
const [items, setItems] = useState(initialItems);
return (
<div>
<SearchInput />
<ExpensiveItemList items={items} />
</div>
);
}
function SearchInput() {
const [search, setSearch] = useState("");
return <input value={search} onChange={(e) => setSearch(e.target.value)} />;
}
When using Context, split contexts by update frequency. A single context that holds everything causes every consumer to re-render on any change.
import { createContext, useContext } from "react";
// Split contexts by what changes together
const ThemeContext = createContext<Theme>("light");
const UserContext = createContext<User | null>(null);
const CartContext = createContext<CartState>({ items: [], total: 0 });
// Components only re-render when their specific context changes
function Header() {
const theme = useContext(ThemeContext);
// Does NOT re-render when cart changes
return <header className={theme}>...</header>;
}
Debouncing expensive updates
For inputs that trigger expensive operations (search, filtering), debounce the update.
import { useState, useDeferredValue, memo } from "react";
function SearchResults() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* Input stays responsive; results update when React has time */}
<Results query={deferredQuery} />
</div>
);
}
const Results = memo(function Results({ query }: { query: string }) {
const filtered = allItems.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
return (
<ul>
{filtered.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
The optimization checklist
Before reaching for memo, useMemo, or useCallback:
- Measure first. Use the Profiler to identify actual bottlenecks. Do not optimize components that render in under 1ms.
- Fix the structure. Moving state down or extracting components often eliminates unnecessary renders without any memoization.
- Check your keys. Incorrect or missing keys in lists cause React to do more work during reconciliation.
- Avoid inline object/array literals in JSX when passing to memoized children. Each render creates a new reference.
- Virtualize long lists. If you render more than 50-100 items, consider virtualization.
- Split your bundle. Lazy load routes and heavy components.
- Then memoize the specific components and values that the Profiler shows as problems.
Performance optimization is about removing waste, not adding complexity. The best optimization is often deleting code or restructuring components, not wrapping everything in memo.
Related articles
- React React Suspense and Lazy Loading Components
Use React.lazy and Suspense to code-split a React app, design fallback UI, place boundaries thoughtfully, and understand how Suspense extends to data fetching.
- React useMemo and useCallback in React: When You Actually Need Them
A practical guide to React's useMemo and useCallback hooks, covering referential equality, when memoization helps performance, when it backfires, and how to profile first.
- React React Performance: Memo, useMemo, and useCallback Guide
Optimize React performance with React.memo, useMemo, and useCallback. Learn when to use each, how to profile, and avoid common pitfalls.
- React React useTransition: Advanced Patterns for Responsive UIs
Go beyond basic useTransition usage with patterns for search, navigation, tab switching, and priority-based rendering in React concurrent mode.