Introduction
React's declarative model is a productivity superpower. You describe what the UI should look like, and React figures out how to make it happen. But this abstraction has a cost: React does a lot of work behind the scenes, and if you don't understand that work, you can accidentally write code that makes React do far more than necessary.
This deep dive covers React's rendering pipeline from first principles. We'll explore the Virtual DOM and reconciliation algorithm, understand exactly when and why components re-render, master React 18's concurrent features, and preview the upcoming React Compiler. By the end, you'll have the mental model to diagnose and fix any React performance issue.
Performance in Context
Before we dive in, a word of caution: premature optimization is the root of all evil. Most React apps are fast enough without any optimization. The patterns in this article should be applied when:
- You have measured a performance problem (not guessed)
- The problem affects user experience (not just benchmarks)
- You've identified the specific cause (not just "it feels slow")
Understanding React's Rendering Pipeline
To optimize React, you need to understand what React actually does when your component "renders." Spoiler: it's more nuanced than you might think.
Rendering vs. Committing
A common misconception is that "rendering" means "updating the DOM." In React, these are two distinct phases:
Render Phase: React calls your component functions to compute what the UI should look like. This produces a Virtual DOM tree (React elements). This phase is pure—no side effects, no DOM mutations.
Commit Phase: React compares the new Virtual DOM with the previous one (reconciliation), computes the minimal set of changes, and applies them to the real DOM.
This distinction matters because the Render phase can be interrupted and restarted (in Concurrent React), but the Commit phase is synchronous and cannot be interrupted.
The Virtual DOM and Reconciliation
React maintains a lightweight JavaScript representation of the DOM called the Virtual DOM. It's essentially a tree of JavaScript objects describing the UI structure:
class="code-comment">// What JSX compiles to
<div className=class="code-string">"card">
<h1>Hello</h1>
</div>
class="code-comment">// Becomes this object (simplified)
{
type: class="code-string">'div',
props: {
className: class="code-string">'card',
children: {
type: class="code-string">'h1',
props: { children: class="code-string">'Hello' }
}
}
}
When state changes, React:
The Reconciliation Algorithm
Comparing two arbitrary trees has O(n³) complexity—unusable for any real application. React uses clever heuristics to achieve O(n) complexity:
Heuristic 1: Elements of different types produce different trees. If you change a Heuristic 2: The Heuristic 3: Components of the same type are assumed to produce similar trees. React reuses the instance and updates props. Let's see these in action: class="code-comment">// class="code-number">2. Same element type = update props, keep instance
<div className=class="code-string">"a"> <div className=class="code-string">"b">
<Counter /> → <Counter /> class="code-comment">// Same Counter instance
</div> </div> class="code-comment">// class="code-number">3. Keys optimize list reconciliation
class="code-comment">// Without keys: O(n) operations class="code-keyword">for list changes
class="code-comment">// With keys: O(class="code-number">1) class="code-keyword">for moves, insertions, deletions class="code-comment">// BAD: Using index ="code-keyword">as key
{items.map((item, index) => (
<Item key={index} {...item} /> class="code-comment">// Causes unnecessary re-renders
))} class="code-comment">// GOOD: Using stable unique ID
{items.map((item) => (
<Item key={item.id} {...item} /> class="code-comment">// Efficient updates
))} The Without keys, React matches elements by position. If you insert an item at the beginning of a list, React thinks every item changed (because they all shifted position) and re-renders everything. With keys, React matches elements by key. Inserting an item at the beginning means React knows existing items just moved—it reuses their DOM nodes and state. Critical Rule: Keys must be stable, unique, and predictable. Using array index as key defeats the purpose because indices change when items are added or removed. Now that we understand when React renders, let's explore how to prevent unnecessary renders. But first, let's be precise about what "unnecessary" means. A component re-renders when:
Notice what's NOT on this list: props changes. A component re-renders when its parent re-renders, regardless of whether its props actually changed. This is a common misconception. Why does React do this? Because checking whether props changed (deep equality) can be expensive and often unnecessary. React optimizes for the common case where props do change when the parent re-renders. class="code-keyword">return (
<div>
<button onClick={() => setCount(c => c + class="code-number">1)}>
Count: {count}
</button>
<ExpensiveChild /> {class="code-comment">/ Re-renders on every click! /}
</div>
);
} Before you start memoizing everything, understand what a re-render actually costs: Steps 1-3 are usually cheap. Step 4 is expensive, but it only happens when the Virtual DOM actually changes. A component can re-render 100 times without touching the DOM if its output doesn't change. The question isn't "is this component re-rendering?" but "is this re-render causing expensive work?" That expensive work might be:
class="code-comment">// Custom comparison class="code-keyword">function
class="code-keyword">const UserCard = memo(
class="code-keyword">function UserCard({ user, onSelect }: Props) {
class="code-keyword">return (
<div onClick={() => onSelect(user.id)}>
{user.name}
</div>
);
},
(prevProps, nextProps) => {
class="code-comment">// Return true class="code-keyword">if props are equal(skip re-render)
class="code-keyword">return prevProps.user.id === nextProps.user.id &&
prevProps.onSelect === nextProps.onSelect;
}
); These hooks help maintain stable references across renders: class="code-comment">// ✅ GOOD: Only recompute when dependencies change
class="code-keyword">const filteredResults = useMemo(() =>
data.filter(item =>
item.name.includes(query) &&
filters.every(f => item.tags.includes(f))
),
[data, query, filters]
); class="code-comment">// ❌ BAD: New ="code-keyword">class="code-keyword">function created every render
class="code-keyword">const handleClick = (id: string) => {
onSelect(id);
}; class="code-comment">// ✅ GOOD: Stable ="code-keyword">class="code-keyword">function reference
class="code-keyword">const handleClick = useCallback((id: string) => {
onSelect(id);
}, [onSelect]); class="code-keyword">return filteredResults.map(item => (
<Item
key={item.id}
item={item}
onClick={handleClick} class="code-comment">// Stable reference
/>
));
} When deciding whether to memoize, ask these questions: Memoization isn't free. Every useMemo and useCallback has overhead: storing the memoized value, comparing dependencies, and the complexity it adds to your code. Here are anti-patterns: class="code-comment">// ❌ Over-optimization: Simple components
class="code-keyword">const SimpleText = memo(({ text }: { text: string }) => (
<span>{text}</span> class="code-comment">// Comparison cost > render cost
)); class="code-comment">// ❌ Unstable dependencies defeat memoization
class="code-keyword">function Parent() {
class="code-comment">// This object is new every render!
class="code-keyword">const config = { theme: class="code-string">'dark', size: class="code-string">'large' }; class="code-keyword">return <Child config={config} />; class="code-comment">// memo is useless
} class="code-comment">// ✅ Fix: Memoize the ="code-type">object
class="code-keyword">function Parent() {
class="code-keyword">const config = useMemo(() => ({
theme: class="code-string">'dark',
size: class="code-string">'large'
}), []); class="code-keyword">return <Child config={config} />;
} React 18 introduced Concurrent React—a fundamental change to how React schedules and renders updates. The key insight is that not all updates are equally urgent. Typing in an input should feel instant, but updating a complex dashboard can happen in the background. Concurrent React enables React to:
Batching means combining multiple state updates into a single re-render. React has always batched updates in event handlers, but React 18 extends this to all contexts: class="code-comment">// React class="code-number">18: Single re-render(automatic batching)
async class="code-keyword">function handleClick() {
class="code-keyword">const data = await fetchData();
setLoading(false); class="code-comment">// Batched
setData(data); class="code-comment">// Batched
setError(null); class="code-comment">// Batched → Single re-render
} class="code-comment">// Opt-out class="code-keyword">if needed
class="code-keyword">import { flushSync } class="code-keyword">from class="code-string">'react-dom'; class="code-keyword">function handleClick() {
flushSync(() => {
setCount(c => c + class="code-number">1); class="code-comment">// Immediately re-renders
});
class="code-comment">// DOM is updated here
console.log(document.getElementById(class="code-string">'count').textContent);
} The classic use case is search: when the user types, updating the input is urgent (they need to see what they typed). But filtering a large list can happen in the background. If the user types another character before filtering completes, we should abandon the old filter and start a new one. class="code-keyword">function handleChange(e: ChangeEvent<HTMLInputElement>) {
class="code-comment">// Urgent: Update input immediately
setQuery(e.target.value); class="code-comment">// Non-urgent: Can be interrupted
startTransition(() => {
class="code-comment">// This won't block typing even with class="code-number">10,class="code-number">000 results
class="code-keyword">const filtered = filterLargeDataset(e.target.value);
setResults(filtered);
});
} class="code-keyword">return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<ResultsList results={results} />
</div>
);
} Both hooks defer updates, but they serve different purposes: class="code-comment">// Show stale content class="code-keyword">while computing new results
class="code-keyword">const isStale = query !== deferredQuery; class="code-comment">// This expensive computation uses the deferred value
class="code-keyword">const results = useMemo(
() => filterLargeDataset(deferredQuery),
[deferredQuery]
); class="code-keyword">return (
<div style={{ opacity: isStale ? class="code-number">0.7 : class="code-number">1 }}>
{results.map(item => <Item key={item.id} item={item} />)}
</div>
);
} class="code-comment">// Using React Query with Suspense
class="code-keyword">function ProfileDetails({ userId }: { userId: string }) {
class="code-keyword">const { data } = useSuspenseQuery({
queryKey: [class="code-string">'user', userId],
queryFn: () => fetchUser(userId),
}); class="code-keyword">return <div>{data.name}</div>;
} class="code-comment">// Streaming SSR with Suspense
class="code-comment">// server.tsx
class="code-keyword">import { renderToPipeableStream } class="code-keyword">from class="code-string">'react-dom/server'; app.get(class="code-string">'/', (req, res) => {
class="code-keyword">const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: [class="code-string">'/main.js'],
onShellReady() {
res.setHeader(class="code-string">'content-type', class="code-string">'text/html');
pipe(res); class="code-comment">// Stream HTML as components resolve
},
});
}); class="code-comment">// Lazy load route components
class="code-keyword">const Dashboard = lazy(() => class="code-keyword">import(class="code-string">'./pages/Dashboard'));
class="code-keyword">const Settings = lazy(() => class="code-keyword">import(class="code-string">'./pages/Settings'));
class="code-keyword">const Analytics = lazy(() =>
class="code-keyword">import(class="code-string">'./pages/Analytics').then(module => ({
default: module.AnalyticsPage class="code-comment">// Named class="code-keyword">export
}))
); class="code-keyword">function App() {
class="code-keyword">return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path=class="code-string">"/dashboard" element={<Dashboard />} />
<Route path=class="code-string">"/settings" element={<Settings />} />
<Route path=class="code-string">"/analytics" element={<Analytics />} />
</Routes>
</Suspense>
);
} class="code-keyword">function Dashboard() {
class="code-keyword">const [showChart, setShowChart] = useState(false); class="code-keyword">return (
<div>
<button onClick={() => setShowChart(true)}>
Show Analytics
</button> {showChart && (
<Suspense fallback={<ChartSkeleton />}>
<HeavyChart data={analyticsData} />
</Suspense>
)}
</div>
);
} class="code-comment">// Preload on hover class="code-keyword">for better UX
class="code-keyword">function NavLink({ to, children }: Props) {
class="code-keyword">const handleMouseEnter = () => {
class="code-comment">// Preload the route component
class="code-keyword">const route = routes.find(r => r.path === to);
route?.component.preload?.();
}; class="code-keyword">return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
} class="code-keyword">function VirtualizedList({ items }: { items: Item[] }) {
class="code-keyword">const parentRef = useRef<HTMLDivElement>(null); class="code-keyword">const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => class="code-number">50, class="code-comment">// Estimated row height
overscan: class="code-number">5, class="code-comment">// Render class="code-number">5 extra items outside viewport
}); class="code-keyword">return (
<div
ref={parentRef}
style={{ height: class="code-string">'400px', overflow: class="code-string">'auto' }}
>
<div
style={{
height: class="code-string"> The React Compiler automatically applies optimizations, reducing the need for manual memoization. class="code-keyword">const handleComplete = useCallback(
(id: string) => completeTodo(id),
[completeTodo]
); class="code-keyword">return filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onComplete={handleComplete}
/>
));
} class="code-comment">// After: React Compiler handles it automatically
class="code-keyword">function TodoList({ todos, filter }: Props) {
class="code-comment">// No useMemo needed - compiler detects and optimizes
class="code-keyword">const filteredTodos = todos.filter(todo =>
matchesFilter(todo, filter)
); class="code-comment">// No useCallback needed - compiler maintains reference stability
class="code-keyword">const handleComplete = (id: string) => completeTodo(id); class="code-keyword">return filteredTodos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onComplete={handleComplete}
/>
));
} class="code-comment">// Enable React Compiler in next.config.js(Next.js class="code-number">15+)
module.exports = {
experimental: {
reactCompiler: true,
},
}; class="code-keyword">function onRenderCallback(
id: string,
phase: class="code-string">'mount' | class="code-string">'update',
actualDuration: number,
baseDuration: number,
startTime: number,
commitTime: number,
) {
console.log({
id,
phase,
actualDuration, class="code-comment">// Time spent rendering
baseDuration, class="code-comment">// Estimated time without memoization
});
} class="code-keyword">function App() {
class="code-keyword">return (
<Profiler id=class="code-string">"App" onRender={onRenderCallback}>
<Dashboard />
</Profiler>
);
} useEffect(() => {
class="code-keyword">const renderTime = performance.now() - renderStart.current; class="code-keyword">if (renderTime > threshold) {
console.warn(
class="code-string"> class="code-comment">// Report to monitoring service
analytics.track(class="code-string">'slow_render', {
component: componentName,
duration: renderTime,
});
}
}); renderStart.current = performance.now();
} class="code-keyword">function ExpensiveComponent() {
useRenderTime(class="code-string">'ExpensiveComponent');
class="code-comment">// ... component logic
} React performance optimization is a deep topic, but it boils down to a few key principles: The React Compiler (in development, available in React 19+) automatically applies memoization. It analyzes your code at build time and inserts useMemo, useCallback, and memo where beneficial. This doesn't mean you can ignore performance—understanding these concepts helps you:
Start simple. Write straightforward React code. When you hit a performance problem: Performance optimization is a skill you develop over time. Build the mental model, learn the tools, and practice. Your users will thank you., React won't try to patch it—it destroys the entire subtree and rebuilds.
key prop hints which elements are stable across renders. This is crucial for lists.class="code-comment">// class="code-number">1. Different element types = replace entire subtree
<div> <span>
<Counter /> → <Counter /> class="code-comment">// Counter is remounted!
</div> </span>
The Key Prop Deep Dive
key prop is one of the most misunderstood React concepts. It's not just for suppressing warnings—it fundamentally changes how React reconciles lists.Preventing Unnecessary Re-renders
Understanding When Components Re-render
class="code-comment">// This child re-renders every time parent renders!
class="code-keyword">function Parent() {
class="code-keyword">const [count, setCount] = useState(class="code-number">0);
The Real Cost of Re-renders
React.memo - The Memoization HOC
React.memo is a higher-order component that memoizes your component. It performs a shallow comparison of props and skips re-rendering if props haven't changed.class="code-comment">// Basic memoization
class="code-keyword">const ExpensiveChild = memo(class="code-keyword">function ExpensiveChild() {
console.log(class="code-string">'ExpensiveChild rendered');
class="code-keyword">return <div>I am expensive</div>;
});
Understanding Shallow Comparison
React.memo uses shallow comparison by default. This means:
This is why object and function props often break memoization—they're created fresh on each render, so their reference changes even if their content is identical.
useMemo and useCallback
They're essentially the same thing—useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
class="code-keyword">function SearchResults({ query, filters }: Props) {
class="code-comment">// ❌ BAD: New array created every render
class="code-keyword">const filteredResults = data.filter(item =>
item.name.includes(query) &&
filters.every(f => item.tags.includes(f))
);
The Memoization Decision Framework
When NOT to Memoize
class="code-comment">// ❌ Over-optimization: Simple calculations
class="code-keyword">const doubled = useMemo(() => count * class="code-number">2, [count]); class="code-comment">// Overkill
React 18 Concurrent Features
Let's explore the specific features.
Automatic Batching
class="code-comment">// React class="code-number">17: Multiple re-renders
async class="code-keyword">function handleClick() {
class="code-keyword">const data = await fetchData();
setLoading(false); class="code-comment">// Re-render class="code-number">1
setData(data); class="code-comment">// Re-render class="code-number">2
setError(null); class="code-comment">// Re-render class="code-number">3
}
useTransition - Non-blocking Updates
useTransition is the most important concurrent feature for application developers. It lets you mark certain state updates as "transitions"—updates that can be interrupted if something more urgent happens.class="code-keyword">function SearchPage() {
class="code-keyword">const [query, setQuery] = useState(class="code-string">'');
class="code-keyword">const [results, setResults] = useState([]);
class="code-keyword">const [isPending, startTransition] = useTransition();
useTransition vs useDeferredValue
Use useTransition when you own the state. Use useDeferredValue when you receive the value from elsewhere.
useDeferredValue - Deferring Re-renders
useDeferredValue accepts a value and returns a "deferred" version that lags behind during rapid updates. This is perfect when you receive a value from outside (props) and can't control when it updates.class="code-keyword">function SearchResults({ query }: { query: string }) {
class="code-comment">// deferredQuery lags behind query during rapid updates
class="code-keyword">const deferredQuery = useDeferredValue(query);
Suspense for Data Fetching
class="code-comment">// Modern data fetching with Suspense
class="code-keyword">function ProfilePage({ userId }: { userId: string }) {
class="code-keyword">return (
<Suspense fallback={<ProfileSkeleton />}>
<ProfileDetails userId={userId} />
<Suspense fallback={<PostsSkeleton />}>
<ProfilePosts userId={userId} />
</Suspense>
</Suspense>
);
}
Code Splitting and Lazy Loading
Route-based Splitting
class="code-keyword">import { lazy, Suspense } class="code-keyword">from class="code-string">'react';
class="code-keyword">import { Routes, Route } class="code-keyword">from class="code-string">'react-router-dom';
Component-level Splitting
class="code-comment">// Lazy load heavy components
class="code-keyword">const HeavyChart = lazy(() => class="code-keyword">import(class="code-string">'./components/HeavyChart'));
class="code-keyword">const RichTextEditor = lazy(() => class="code-keyword">import(class="code-string">'./components/RichTextEditor'));
Virtualization for Large Lists
class="code-keyword">import { useVirtualizer } class="code-keyword">from class="code-string">'@tanstack/react-virtual';
${virtualizer.getTotalSize()}px,
position: class="code-string">'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: class="code-string">'absolute',
top: class="code-number">0,
left: class="code-number">0,
width: class="code-string">'class="code-number">100%',
height: class="code-string">${virtualItem.size}px,
transform: class="code-string">translateY(${virtualItem.start}px),
}}
>
<ItemRow item={items[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}The React Compiler (React 19+)
class="code-comment">// Before: Manual memoization required
class="code-keyword">function TodoList({ todos, filter }: Props) {
class="code-keyword">const filteredTodos = useMemo(
() => todos.filter(todo => matchesFilter(todo, filter)),
[todos, filter]
);
Performance Profiling
React DevTools Profiler
class="code-comment">// Wrap components to measure render time
class="code-keyword">import { Profiler } class="code-keyword">from class="code-string">'react';
Custom Performance Monitoring
class="code-comment">// Hook to detect slow renders
class="code-keyword">function useRenderTime(componentName: string, threshold = class="code-number">16) {
class="code-keyword">const renderStart = useRef(performance.now());
Slow render: ${componentName} took ${renderTime.toFixed(class="code-number">2)}ms
);Conclusion
The Mental Model
The Optimization Toolkit
The Future: React Compiler
Final Advice