React Performance Optimization: From Virtual DOM to Concurrent Rendering
Back to Blog
ReactPerformanceReact 18Concurrent RenderingOptimization

React Performance Optimization: From Virtual DOM to Concurrent Rendering

Bao Trong
Bao Trong
January 10, 2026
28 min read

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")
With that said, understanding these concepts is valuable even if you never need to optimize. It makes you a better React developer and helps you write performant code by default.

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' } } } }

typescript

When state changes, React:

  • Calls your component functions to create a new Virtual DOM tree
  • Compares it with the previous tree (this is "reconciliation" or "diffing")
  • Computes the minimal set of DOM operations needed
  • Applies those changes to the real DOM
  • React Rendering Pipeline

    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

    to a , React won't try to patch it—it destroys the entire subtree and rebuilds.

    Heuristic 2: The key prop hints which elements are stable across renders. This is crucial for lists.

    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">1. Different element types = replace entire subtree
    <div>                  <span>
      <Counter />    →       <Counter />  class="code-comment">// Counter is remounted!
    </div>                 </span>
    

    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 ))}

    typescript

    The Key Prop Deep Dive

    The key prop is one of the most misunderstood React concepts. It's not just for suppressing warnings—it fundamentally changes how React reconciles lists.

    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.

    Preventing Unnecessary Re-renders

    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.

    Understanding When Components Re-render

    A component re-renders when:

  • Its state changes (via setState, useState setter, etc.)
  • Its parent re-renders (unless the component is memoized)
  • A context it consumes changes (any component using useContext re-renders)
  • 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-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);
    

    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> ); }

    typescript

    The Real Cost of Re-renders

    Before you start memoizing everything, understand what a re-render actually costs:

  • Function execution: React calls your component function
  • Element creation: JSX creates React element objects
  • Reconciliation: React compares old and new elements
  • DOM updates: Only if the Virtual DOM changed
  • 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:

    • Complex calculations during render
    • Large lists being recreated
    • Heavy child components being re-rendered
    • Actual DOM updates

    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>;
    });
    

    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; } );

    typescript

    Understanding Shallow Comparison

    React.memo uses shallow comparison by default. This means:

    • Primitives (strings, numbers, booleans) are compared by value
    • Objects and arrays are compared by reference
    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

    These hooks help maintain stable references across renders:

    • useMemo: Memoizes a computed value. Returns the same value if dependencies haven't changed.
    • useCallback: Memoizes a function. Returns the same function reference if dependencies haven't changed.
    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))
      );
    

    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 /> )); }

    typescript

    The Memoization Decision Framework

    When deciding whether to memoize, ask these questions:

  • Is this component expensive to render? If it's a simple component, memoization overhead might exceed the render cost.
  • Does this component re-render often with the same props? If props usually change, memoization just adds comparison overhead.
  • Is the parent's render expensive? Sometimes it's better to optimize the parent than memoize the child.
  • Are the props stable? If props are recreated every render (objects, arrays, functions), you need useMemo/useCallback in the parent too.
  • When NOT to Memoize

    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 calculations
    class="code-keyword">const doubled = useMemo(() => count * class="code-number">2, [count]);  class="code-comment">// Overkill
    

    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} />; }

    typescript

    React 18 Concurrent Features

    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:

    • Interrupt rendering to handle more urgent updates
    • Keep the UI responsive during expensive renders
    • Prepare new UI in the background before showing it
    Let's explore the specific features.

    Automatic Batching

    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">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
    }
    

    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); }

    typescript

    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.

    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 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();
    

    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> ); }

    typescript

    useTransition vs useDeferredValue

    Both hooks defer updates, but they serve different purposes:

    • useTransition: You control the state setter. Wrap the non-urgent setState call.
    • useDeferredValue: You receive a value (e.g., from props). Defer its updates.
    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);
    

    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> ); }

    typescript

    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>
      );
    }
    

    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 }, }); });

    typescript

    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';
    

    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> ); }

    typescript

    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'));
    

    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> ); }

    typescript

    Virtualization for Large Lists

    class="code-keyword">import { useVirtualizer } class="code-keyword">from class="code-string">'@tanstack/react-virtual';
    

    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">${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> ); }

    typescript

    The React Compiler (React 19+)

    The React Compiler automatically applies optimizations, reducing the need for manual memoization.

    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]
      );
    

    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, }, };

    typescript

    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';
    

    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> ); }

    typescript

    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());
    

    useEffect(() => { class="code-keyword">const renderTime = performance.now() - renderStart.current;

    class="code-keyword">if (renderTime > threshold) { console.warn( class="code-string">Slow render: ${componentName} took ${renderTime.toFixed(class="code-number">2)}ms );

    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 }

    typescript

    Conclusion

    React performance optimization is a deep topic, but it boils down to a few key principles:

    The Mental Model

  • Rendering isn't the problem—unnecessary work is. A component can re-render thousands of times without performance issues if each render is fast and doesn't cause DOM updates.
  • Measure before optimizing. Use React DevTools Profiler to identify actual bottlenecks. Don't guess—you'll often be wrong.
  • Optimize at the right level. Sometimes the fix is restructuring components, not adding memoization. State colocation (keeping state close to where it's used) often eliminates entire classes of re-render issues.
  • The Optimization Toolkit

    • React.memo: Prevent re-renders when props haven't changed. Use for expensive components with stable props.
    • useMemo/useCallback: Maintain stable references for objects and functions passed to memoized children.
    • useTransition/useDeferredValue: Keep the UI responsive during expensive updates. Essential for search, filtering, and data-heavy UIs.
    • Code Splitting: Don't load code the user doesn't need yet. Split by route and by component.
    • Virtualization: For lists over ~100 items, render only what's visible.

    The Future: React Compiler

    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:

    • Debug when things go wrong
    • Handle cases the compiler can't optimize
    • Write code that's easier for the compiler to optimize

    Final Advice

    Start simple. Write straightforward React code. When you hit a performance problem:

  • Measure to identify the bottleneck
  • Understand why it's slow (re-renders? Expensive calculations? DOM updates?)
  • Fix with the lightest-weight solution
  • Verify the fix actually helped
  • Performance optimization is a skill you develop over time. Build the mental model, learn the tools, and practice. Your users will thank you.