React Performance Optimization — Profile, measure, and prevent unnecessary re-renders
Recipe
import { memo, useMemo, useCallback, useTransition } from "react";
// Memoize expensive component
const ExpensiveList = memo(function ExpensiveList({
items,
onSelect,
}: {
items: Item[];
onSelect: (id: string) => void;
}) {
return (
<ul>
{items.map((item) => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// Parent: stabilize props passed to memoized children
function Parent() {
const [items] = useState<Item[]>(initialItems);
const [selected, setSelected] = useState<string | null>(null);
// Stable callback reference
const handleSelect = useCallback((id: string) => {
setSelected(id);
}, []);
return <ExpensiveList items={items} onSelect={handleSelect} />;
}When to reach for this: Only after profiling reveals a performance problem. Premature optimization adds complexity for no gain. Use React DevTools Profiler to measure first, then apply memo, useMemo, or useCallback where they actually help.
Working Example
import {
memo,
useMemo,
useCallback,
useState,
useTransition,
useDeferredValue,
} from "react";
// --- Virtualized list with memoized rows ---
interface Contact {
id: string;
name: string;
email: string;
avatar: string;
}
const ContactRow = memo(function ContactRow({
contact,
isSelected,
onSelect,
}: {
contact: Contact;
isSelected: boolean;
onSelect: (id: string) => void;
}) {
return (
<div
onClick={() => onSelect(contact.id)}
className={`flex items-center gap-3 p-3 cursor-pointer ${
isSelected ? "bg-blue-50 border-l-4 border-blue-500" : "hover:bg-gray-50"
}`}
>
<img
src={contact.avatar}
alt=""
className="w-10 h-10 rounded-full"
loading="lazy"
/>
<div>
<p className="font-medium">{contact.name}</p>
<p className="text-sm text-gray-500">{contact.email}</p>
</div>
</div>
);
});
// --- Search with deferred value ---
function ContactList({ contacts }: { contacts: Contact[] }) {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// Expensive filter only re-runs when deferredQuery or contacts change
const filtered = useMemo(
() =>
contacts.filter(
(c) =>
c.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
c.email.toLowerCase().includes(deferredQuery.toLowerCase())
),
[contacts, deferredQuery]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
return (
<div className="max-w-md border rounded-lg overflow-hidden">
<div className="p-3 border-b">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search contacts..."
className="w-full px-3 py-2 border rounded"
/>
</div>
<div
className={`max-h-96 overflow-y-auto ${isStale ? "opacity-60" : ""}`}
>
{filtered.length === 0 ? (
<p className="p-4 text-gray-500 text-center">No contacts found</p>
) : (
filtered.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
isSelected={contact.id === selectedId}
onSelect={handleSelect}
/>
))
)}
</div>
<div className="p-2 border-t text-sm text-gray-400 text-center">
{filtered.length} of {contacts.length} contacts
</div>
</div>
);
}
// --- Transition for tab switching ---
function TabPanel({ tabs }: { tabs: { id: string; label: string; content: () => JSX.Element }[] }) {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const [isPending, startTransition] = useTransition();
const handleTabChange = (tabId: string) => {
startTransition(() => {
setActiveTab(tabId);
});
};
const ActiveContent = tabs.find((t) => t.id === activeTab)?.content;
return (
<div>
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
className={`px-4 py-2 -mb-px ${
activeTab === tab.id
? "border-b-2 border-blue-500 font-medium"
: "text-gray-500"
}`}
>
{tab.label}
</button>
))}
</div>
<div className={`p-4 ${isPending ? "opacity-50 transition-opacity" : ""}`}>
{ActiveContent && <ActiveContent />}
</div>
</div>
);
}What this demonstrates:
memoonContactRowskips re-renders when props haven't changeduseCallbackstabilizeshandleSelectso it doesn't breakmemouseDeferredValuekeeps the search input responsive while filtering is deferreduseMemocaches the filtered list to avoid re-computing on every keystrokeuseTransitionmarks tab switching as non-urgent, keeping interactions smooth
Deep Dive
How It Works
- React re-renders a component when its state changes, its parent re-renders, or a context it consumes changes.
React.memo(Component)wraps a component so it only re-renders when its props change (shallow comparison by default).useMemo(fn, deps)caches a computed value and only recomputes when dependencies change.useCallback(fn, deps)caches a function reference — equivalent touseMemo(() => fn, deps).useDeferredValue(value)returns a deferred copy that lags behind during urgent updates, allowing the input to stay responsive.useTransitionwraps state updates as non-urgent, allowing React to interrupt the render if a more urgent update arrives.- The React compiler (experimental) can auto-memoize components and hooks, potentially making manual
memo/useMemo/useCallbackunnecessary.
Parameters & Return Values
| API | Input | Output | Re-render Behavior |
|---|---|---|---|
memo(Component, areEqual?) | Component, optional comparator | Memoized component | Skips render if props are shallowly equal |
useMemo(fn, deps) | Factory function, dependency array | Cached value of type T | Recomputes only when deps change |
useCallback(fn, deps) | Function, dependency array | Stable function reference | New reference only when deps change |
useDeferredValue(value) | Any value | Deferred copy of value | Updates with lower priority |
useTransition() | None | [isPending, startTransition] | Marks updates as interruptible |
Variations
Custom comparison for memo:
const Chart = memo(
function Chart({ data, config }: ChartProps) {
// Expensive render
return <canvas />;
},
(prev, next) => {
// Only re-render if data length or config changes
return (
prev.data.length === next.data.length &&
prev.config.type === next.config.type
);
}
);Profiling with React DevTools:
import { Profiler, type ProfilerOnRenderCallback } from "react";
const onRender: ProfilerOnRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) => {
console.table({ id, phase, actualDuration, baseDuration });
};
function App() {
return (
<Profiler id="ContactList" onRender={onRender}>
<ContactList contacts={contacts} />
</Profiler>
);
}Avoiding re-renders with children pattern:
// This causes SearchResults to re-render when count changes:
function BadParent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>{count}</button>
<SearchResults /> {/* Re-renders unnecessarily */}
</div>
);
}
// Fix: lift state into a smaller component
function GoodParent() {
return (
<div>
<Counter /> {/* count state scoped here */}
<SearchResults /> {/* No longer re-renders */}
</div>
);
}
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}TypeScript Notes
memopreserves the component's prop types. Generic components require a wrapper:memo(Component) as typeof Component.useMemo<T>anduseCallbackinfer types from the factory function. Explicit type annotations are rarely needed.- The
areEqualfunction inmemoreceives(prevProps, nextProps)and should returntrueto skip re-render.
Gotchas
-
Premature optimization — Adding
memo,useMemo, anduseCallbackeverywhere adds complexity and can actually slow down fast components due to comparison overhead. Fix: Profile first with React DevTools. Only optimize components that show measurable re-render cost. -
Unstable prop references breaking memo — Passing
onClick={() => {}}orstyle={{ color: "red" }}inline creates new references each render, defeatingmemo. Fix: UseuseCallbackfor functions anduseMemofor objects/arrays passed to memoized children. -
Missing dependency in useMemo/useCallback — Omitting a dependency leads to stale closures. Fix: Include all referenced values in the dependency array. Use the
react-hooks/exhaustive-depsESLint rule. -
Memoizing cheap computations —
useMemofor a simple string concatenation costs more than just recomputing it. Fix: Only memoize computations that are actually expensive (filtering large arrays, complex calculations, creating objects for context). -
Key prop resetting memo — Changing a component's
keycauses React to unmount and remount it, regardless ofmemo. Fix: Use stable keys (IDs, not array indices) unless you intentionally want a reset. -
Large component trees without virtualization — Memoizing 10,000 list items is slower than virtualizing to render only visible items. Fix: Use
@tanstack/react-virtual,react-window, or CSScontent-visibility: autofor large lists.
Alternatives
| Approach | Trade-off |
|---|---|
React.memo + useCallback | Manual but precise; boilerplate |
| React Compiler (experimental) | Automatic memoization; experimental, not production-ready |
| Virtualization | Renders only visible items; more complex scrolling behavior |
| State colocation | Move state closer to where it's used; simplest fix |
useDeferredValue | Keeps UI responsive; shows stale data briefly |
CSS content-visibility | Browser-native lazy rendering; no React API needed |
| Web Workers | Offload computation; complex data transfer |
FAQs
When should you add React.memo, useMemo, or useCallback to a component?
- Only after profiling with React DevTools Profiler reveals a measurable re-render cost.
- Premature optimization adds complexity and comparison overhead that can slow down fast components.
- Focus on components that render large lists, expensive computations, or deep trees.
What triggers a React component to re-render?
- Its own state changes via
useStateoruseReducer. - Its parent re-renders (even if no props changed, unless wrapped in
memo). - A context it consumes changes.
How does useDeferredValue keep the UI responsive during expensive filtering?
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
const filtered = useMemo(
() => contacts.filter((c) => c.name.includes(deferredQuery)),
[contacts, deferredQuery]
);- The input updates immediately with
query, butdeferredQuerylags behind. - The expensive filter runs on the deferred value, so typing stays responsive.
isStalecan be used to show a visual indicator (e.g., reduced opacity) while filtering catches up.
What is the difference between useTransition and useDeferredValue?
useTransitionwraps a state update to mark it as non-urgent, keeping the current UI visible.useDeferredValuecreates a deferred copy of a value that updates with lower priority.- Use
useTransitionwhen you control the state update. UseuseDeferredValuewhen you receive a value from props.
Gotcha: Why does passing inline objects or functions as props break React.memo?
- Inline
onClick={() => {}}orstyle={{ color: "red" }}creates a new reference every render. React.memocompares props by reference, so it sees a "change" and re-renders.- Fix: use
useCallbackfor functions anduseMemofor objects/arrays passed to memoized children.
Gotcha: Why is memoizing cheap computations actually slower?
useMemohas overhead: it stores the previous value, compares dependencies, and manages memory.- For a simple string concatenation or basic arithmetic, recomputing is faster than the memoization overhead.
- Only memoize computations that are actually expensive (filtering large arrays, complex calculations).
How does the children-as-props pattern avoid unnecessary parent re-renders?
// Bad: SearchResults re-renders when count changes
function BadParent() {
const [count, setCount] = useState(0);
return <div><button onClick={() => setCount(c => c + 1)}>{count}</button><SearchResults /></div>;
}
// Good: move state into a smaller component
function GoodParent() {
return <div><Counter /><SearchResults /></div>;
}- Moving state into a smaller component scopes re-renders to only that component.
SearchResultsno longer re-renders because its parent (GoodParent) does not own the changing state.
How does memo interact with generic components in TypeScript?
memo(Component)can lose generic type parameters.- Fix: cast the result back:
memo(Component) as typeof Component. - This preserves the generic signature for consumers.
What does the areEqual function in React.memo do in TypeScript?
const Chart = memo(
function Chart({ data, config }: ChartProps) { return <canvas />; },
(prev, next) => prev.data.length === next.data.length && prev.config.type === next.config.type
);- It receives
(prevProps, nextProps)and returnstrueto skip re-render,falseto re-render. - Use it for custom comparison logic when shallow equality is not enough.
When should you use virtualization instead of memoization for large lists?
- When you have thousands of items, memoizing each one is slower than rendering only the visible items.
- Libraries like
@tanstack/react-virtualandreact-windowrender only items in the viewport. - CSS
content-visibility: autoprovides browser-native lazy rendering without a React library.
What is the React Compiler and how does it relate to manual memoization?
- The React Compiler (experimental) can auto-memoize components and hooks at build time.
- It could make manual
memo,useMemo, anduseCallbackunnecessary. - It is not yet production-ready, so manual memoization is still the standard approach.
Related
- Context Patterns — Splitting context to reduce re-renders
- Suspense —
useTransitionanduseDeferredValuefor async rendering - Composition — Children-as-props pattern avoids parent re-renders