React Performance: Patterns That Move the Needle
Practical React performance techniques — memoization, virtualization, code splitting and profiling — with real before/after benchmarks and their tradeoffs.

Performance Is a Feature
Slow UIs cost money. A 100ms delay in load time can reduce conversion rates by 1%. A janky scroll experience drives users away. Performance is not a nice-to-have — it's a product requirement.
But optimization done wrong wastes engineering time and adds complexity with no measurable gain. This guide focuses on changes that actually move the needle.
Step 0: Profile Before You Optimize
Every performance fix should start with measurement. React DevTools Profiler and Chrome Performance tab are your best friends.
// Enable profiling in production builds
// next.config.ts
const nextConfig = {
experimental: {
reactCompiler: true, // React 19+
},
productionBrowserSourceMaps: true,
};Record a performance trace, identify the components with the longest render times, and fix those first. Optimizing a component that renders in 2ms is wasted effort.
Pattern 1: Memoization — Use It Surgically
React.memo, useMemo, and useCallback have a cost: the comparison function runs every render. Memoization only wins when the component is expensive AND the props are stable.
// ❌ Over-memoizing cheap components — adds overhead, no benefit
const SimpleLabel = React.memo(({ text }: { text: string }) => (
<span>{text}</span>
));
// ✅ Memoize expensive computations
const DataGrid = React.memo(({ rows, columns }: DataGridProps) => {
return <VirtualizedTable rows={rows} columns={columns} />;
}, (prev, next) => {
// Custom comparison — shallow equality isn't enough for arrays
return prev.rows === next.rows && prev.columns === next.columns;
});
// ✅ Stabilize callbacks passed to memoized children
function Dashboard({ userId }: { userId: string }) {
const handleRowClick = useCallback((rowId: string) => {
router.push(`/items/${rowId}`);
}, []); // No dependencies — stable across renders
return <DataGrid rows={rows} onRowClick={handleRowClick} />;
}Rule of thumb: memoize when you can measure the improvement.
Pattern 2: Virtualization for Long Lists
Rendering 10,000 DOM nodes is the fastest way to kill performance. Virtualization renders only visible items.
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualList({ items }: { items: Item[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // estimated row height in px
overscan: 5, // render 5 extra items above/below viewport
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: "100%",
height: `${virtualItem.size}px`,
}}
>
<ListRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}For 10,000 items: rendering drops from ~8 seconds to ~50ms. This is not marginal — it's transformational.
Pattern 3: Code Splitting and Lazy Loading
Every kilobyte of JavaScript blocks rendering. Split your bundle at route and component boundaries.
import { lazy, Suspense } from "react";
// Route-level splitting — Next.js handles this automatically
// Component-level splitting for heavy, conditionally rendered components
const RichTextEditor = lazy(() => import("@/components/RichTextEditor"));
const DataVisualization = lazy(() => import("@/components/DataVisualization"));
function ArticleEditor({ article }: { article: Article }) {
const [showPreview, setShowPreview] = useState(false);
return (
<div>
<Suspense fallback={<EditorSkeleton />}>
<RichTextEditor content={article.content} />
</Suspense>
{showPreview && (
<Suspense fallback={<div>Loading preview...</div>}>
<DataVisualization data={article.metrics} />
</Suspense>
)}
</div>
);
}Combined with next/dynamic, this gives you fine-grained control over what ships in the initial bundle.
Pattern 4: State Colocation
Global state causes global re-renders. The fix is often architectural, not a React API call.
// ❌ Storing UI state in a global store — every subscriber re-renders
const useStore = create((set) => ({
isDropdownOpen: false,
toggleDropdown: () => set((s) => ({ isDropdownOpen: !s.isDropdownOpen })),
}));
// ✅ Keep UI state local to the component that owns it
function SearchBar() {
const [isOpen, setIsOpen] = useState(false); // Only SearchBar re-renders
return (
<div>
<input onFocus={() => setIsOpen(true)} onBlur={() => setIsOpen(false)} />
{isOpen && <SearchSuggestions />}
</div>
);
}Ask: "Who actually needs to know about this state?" If the answer is one component, keep it local.
Pattern 5: Transitions for Non-Urgent Updates
React 18's useTransition marks updates as non-urgent, keeping the UI responsive during expensive renders.
import { useTransition, useState } from "react";
function SearchPage() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Result[]>([]);
const [isPending, startTransition] = useTransition();
function handleSearch(value: string) {
setQuery(value); // Urgent: update input immediately
startTransition(() => {
// Non-urgent: can be interrupted if user types again
setResults(filterLargeDataset(value));
});
}
return (
<div>
<input value={query} onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<ResultList results={results} />
</div>
);
}The result: the input stays crisp even while the result list is computing.
Measuring Success
Before shipping any optimization:
| Metric | Before | After | Tool |
|---|---|---|---|
| Time to Interactive | 4.2s | 1.8s | Lighthouse |
| Largest Contentful Paint | 3.1s | 1.2s | Chrome DevTools |
| Component render time | 320ms | 18ms | React Profiler |
| Bundle size (initial) | 840KB | 280KB | next build |
If you can't fill in this table, you haven't finished the optimization.
The Golden Rule
Performance work that ships is worth more than perfect work that doesn't. Start with the biggest wins — virtualization and code splitting typically deliver 10x improvements. Memoization is the last 10%.


