Skip to main content

Ever clicked a button in a React app and wondered what actually happens in those milliseconds before the UI updates? Or why your colleague insists on proper key props in lists? Understanding React's internals isn't just satisfying

I'm going to take you on a journey into React's core. We'll crack open the black box and see exactly how React transforms your JSX into lightning-fast UI updates. By the end, you'll think differently about every line of React code you write.

The Problem React Solved

Back in 2011, Facebook had a problem. Their chat application was a mess of jQuery spaghetti code. Every message notification required manually finding DOM nodes, updating text, adding classes, triggering animations—all by hand. With thousands of users chatting simultaneously, the UI would freeze, stutter, and show stale data.

The real DOM is slow. Not because it's poorly designed, but because it's doing a lot:

// This innocent-looking line does SO much work
document.getElementById('user-count').textContent = '1,234 users online';

Behind the scenes:

  1. Browser recalculates styles (reflow)

  2. Repaints the affected area

  3. Triggers layout shifts

  4. Potentially affects other elements

  5. Blocks the main thread during all of this

Now imagine doing this hundreds of times per second. Your app crawls to a halt.

React's creators asked: "What if we never directly touched the DOM unless absolutely necessary?"

The Virtual DOM: A Lightweight Copy

Here's the insight that changed everything: JavaScript is fast, the DOM is slow.

React creates a fake DOM—a lightweight copy made of plain JavaScript objects. Instead of expensive DOM operations, React works with these cheap objects, figures out the minimal changes needed, and then applies them to the real DOM in one efficient batch.

Think of it like planning a renovation. You don't knock down a wall, wait for inspection, paint a room, wait, install a window, wait. You plan everything, calculate exactly what needs to change, then execute all changes in one coordinated effort.

Let's see the Virtual DOM in action:

// You write this JSX
function UserProfile({ user }) {
  return (
    <div className="profile">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <button onClick={handleFollow}>Follow</button>
    </div>
  );
}

React internally converts this to a Virtual DOM object (simplified):

{
  type: 'div',
  props: {
    className: 'profile',
    children: [
      {
        type: 'img',
        props: { src: 'avatar.jpg', alt: 'John Doe' }
      },
      {
        type: 'h2',
        props: { children: 'John Doe' }
      },
      {
        type: 'p',
        props: { children: 'Software developer...' }
      },
      {
        type: 'button',
        props: { 
          onClick: handleFollow,
          children: 'Follow'
        }
      }
    ]
  }
}

This is just a nested JavaScript object. Creating it takes microseconds. Comparing two of these objects? Also microseconds. Updating the real DOM? Milliseconds—thousands of times slower.

That's why React keeps two Virtual DOM trees in memory: the current one and the next one.

What Happens When State Changes

Let's trace exactly what happens when you click that "Follow" button:

function UserProfile({ user }) {
  const [isFollowing, setIsFollowing] = useState(false);
  
  const handleFollow = () => {
    setIsFollowing(true);
  };
  
  return (
    <div className="profile">
      <h2>{user.name}</h2>
      <button onClick={handleFollow}>
        {isFollowing ? 'Following' : 'Follow'}
      </button>
      <p>{isFollowing ? 'Thanks for following!' : 'Click to follow'}</p>
    </div>
  );
}

Here's the step-by-step process:

Step 1: Scheduling

User clicks button → onClick handler runs → setIsFollowing(true) called → React marks this component as "needs update" → React schedules a re-render

React doesn't update immediately. It batches multiple state changes together for efficiency.

Step 2: Render Phase

React calls UserProfile() again with new state → Function executes with isFollowing = true → JSX is evaluated with new values → New Virtual DOM tree is created → Old Virtual DOM tree is kept for comparison

Your component function runs again, but nothing has touched the real DOM yet. This is pure computation—fast and risk-free.

Step 3: Reconciliation

This is where it gets interesting. React doesn't just replace everything. It runs its diffing algorithm to find the minimal changes.

Old Virtual DOM:
<div className="profile">
  <h2>John Doe</h2>
  <button onClick={...}>Follow</button>
  <p>Click to follow</p>
</div>

New Virtual DOM:
<div className="profile">
  <h2>John Doe</h2>
  <button onClick={...}>Following</button>
  <p>Thanks for following!</p>
</div>

React's diffing process:

  1. <div className="profile"> - Same type, same props → SKIP

  2. <h2>John Doe</h2> - Same type, same text → SKIP

  3. <button> - Same type, different text → UPDATE TEXT ONLY

  4. <p> - Same type, different text → UPDATE TEXT ONLY

Result: Only 2 DOM operations instead of rebuilding everything.

Step 4: Commit Phase

React applies the calculated changes to the real DOM:

// Real DOM operations React performs (conceptually):
buttonElement.textContent = 'Following';
paragraphElement.textContent = 'Thanks for following!';

Two surgical DOM updates. Fast. Efficient.

The Diffing Algorithm

React's diffing algorithm is based on a smart compromise. A perfect diff algorithm would take O(n³) time—way too slow for a UI framework. React achieves O(n) by making two assumptions:

Assumption 1: Different Types = Different Trees

If an element changes type, React gives up on diffing and rebuilds the whole subtree.

// Render 1
{isLoading ? (
  <Spinner />
) : (
  <UserDashboard user={user} />
)}

// Render 2 (isLoading changes to false)

When isLoading becomes false:

  • React sees: <Spinner /><UserDashboard />

  • Different component types

  • Action: Unmount entire <Spinner /> subtree, mount fresh <UserDashboard /> subtree

  • This means <UserDashboard /> starts with a clean slate

Why this matters:

// Antipattern: Component loses state unnecessarily
function Content({ type }) {
  return type === 'article' ? (
    <div><Editor /></div>
  ) : (
    <section><Editor /></section>
  );
}
// When type changes, Editor loses all its state

// Better: Keep structure stable
function Content({ type }) {
  return (
    <div className={type}>
      <Editor />
    </div>
  );
}
// Editor maintains state across re-renders

Assumption 2: Keys Give Elements Identity

This is where many developers struggle. Let's see why keys are critical.

Scenario: User deletes the first item from a list

// Before deletion
<ul>
  <li>Buy milk</li>
  <li>Walk dog</li>
  <li>Write code</li>
</ul>

// After deleting "Buy milk"
<ul>
  <li>Walk dog</li>
  <li>Write code</li>
</ul>

Without keys (React uses array index):

Position 0: "Buy milk" → "Walk dog" (CHANGED - Update text)
Position 1: "Walk dog" → "Write code" (CHANGED - Update text)
Position 2: "Write code" → undefined (Remove element)

Result: 3 DOM operations (2 updates + 1 removal)

With proper keys:

// Before
<ul>
  <li key="task-1">Buy milk</li>
  <li key="task-2">Walk dog</li>
  <li key="task-3">Write code</li>
</ul>

// After
<ul>
  <li key="task-2">Walk dog</li>
  <li key="task-3">Write code</li>
</ul>
key="task-1": Deleted (Remove element)
key="task-2": Same text, moved up (Keep element, no changes)
key="task-3": Same text, moved up (Keep element, no changes)

Result: 1 DOM operation (1 removal). 3x more efficient.

Real-World Impact

Imagine a chat app with 1,000 messages. A new message arrives at the top.

Without keys: React thinks every message changed position, updates 1,000 DOM nodes, UI freezes for 100ms+

With keys: React knows 999 messages are identical and 1 is new, inserts 1 DOM node, UI stays smooth

Key selection best practices:

// Bad: Breaks when list reorders
items.map((item, index) => <Item key={index} {...item} />)

// Bad: Doesn't help React optimize
items.map(item => <Item key={Math.random()} {...item} />)

// Good: Stable, unique identifier
items.map(item => <Item key={item.id} {...item} />)

// Also good: Combined unique identifier
items.map(item => <Item key={`${item.category}-${item.id}`} {...item} />)

Component Reconciliation

React doesn't just diff DOM elements—it diffs your components too:

function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <Header />
      <Counter count={count} />
      <Footer />
    </div>
  );
}

When count changes, React:

  1. Header: Checks if props changed → No → Skips re-render (if memoized)

  2. Counter: Checks if props changed → Yes (count prop) → Re-renders

  3. Footer: Checks if props changed → No → Skips re-render (if memoized)

Without memoization:

function Header() {
  console.log('Header rendered!'); // Logs every time App renders
  return <header>My App</header>;
}

With memoization:

const Header = React.memo(function Header() {
  console.log('Header rendered!'); // Only logs once
  return <header>My App</header>;
});

React.memo tells React: "Only re-render this component if its props actually changed."

Fiber Architecture

In 2017, React released its biggest internal rewrite: Fiber architecture. This fundamentally changed how reconciliation works.

The Old Problem

Pre-Fiber React (React 15 and earlier) would start reconciliation and couldn't stop until finished. The main thread was blocked. If you had 10,000 components to diff, the UI would freeze for 100ms and users couldn't interact.

Fiber's Solution: Time-Slicing

Fiber breaks work into small units that can be paused:

User clicks button
  → React starts reconciliation
    → Diff 100 components
      → PAUSE (5ms passed, check for user input)
        → No input, continue
          → Diff 100 more components
            → PAUSE (animation frame needed)
              → Render animation
                → Resume diffing

Every component render can be interrupted. React can pause work in progress, handle urgent updates (like text input), resume where it left off, or throw away work that's no longer needed.

Priority Levels

Not all updates are equal:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  return (
    <>
      {/* HIGH PRIORITY: User typing */}
      <input 
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      
      {/* LOW PRIORITY: Background data fetch */}
      <ResultsList results={results} />
    </>
  );
}

Fiber priorities:

  1. Immediate: User input, clicking, typing

  2. User-blocking: Animations, scrolling

  3. Normal: Data fetching, network responses

  4. Low: Analytics, logging

  5. Idle: Background tasks

This is why React 18's useTransition works:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = (e) => {
    // High priority: update input immediately
    setQuery(e.target.value);
    
    // Low priority: expensive re-render can be interrupted
    startTransition(() => {
      setResults(expensiveSearch(e.target.value));
    });
  };
  
  return (
    <>
      <input value={query} onChange={handleSearch} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

What happens:

  1. User types "r" → Input updates instantly (high priority)

  2. React starts calculating expensive search results (low priority)

  3. User types "e" → React interrupts the search calculation

  4. Input updates to "re" instantly

  5. React restarts search calculation from scratch

  6. User types "a" → Process repeats

  7. User stops typing → React completes "rea" search calculation

The input never feels sluggish, even though search is expensive.

Render vs Commit Phases

Understanding these two phases is crucial:

RENDER PHASE (Pure, can be interrupted):
  - Call component functions
  - Execute hooks
  - Build Virtual DOM tree
  - Run diffing algorithm
  - Calculate needed changes
      ↓
COMMIT PHASE (Side effects, must be synchronous):
  - Update real DOM
  - Run useLayoutEffect
  - Browser paints screen
  - Run useEffect (scheduled)

Why this matters:

// Don't do this: Side effects in render
function BadComponent() {
  console.log('Rendered!'); // Might log multiple times
  localStorage.setItem('count', count); // Might write multiple times
  
  return <div>{count}</div>;
}

// Do this: Side effects in useEffect
function GoodComponent() {
  useEffect(() => {
    console.log('Committed!'); // Logs once per commit
    localStorage.setItem('count', count); // Writes once per commit
  }, [count]);
  
  return <div>{count}</div>;
}

The render phase can execute multiple times (especially with Concurrent Mode), but the commit phase always executes exactly once.

Performance Optimization

Now that you understand the internals, these optimizations make sense:

1. Use Proper Keys in Lists

// Real example: Draggable list
function TaskList({ tasks }) {
  return (
    <ul>
      {tasks.map(task => (
        // Using stable ID lets React preserve DOM nodes when reordering
        <DraggableTask key={task.id} task={task} />
      ))}
    </ul>
  );
}

2. Memoize Expensive Components

// This component takes 50ms to render
const ExpensiveChart = React.memo(function ExpensiveChart({ data }) {
  // Heavy D3 rendering
  return <svg>...</svg>;
});

function Dashboard() {
  const [unrelatedState, setUnrelatedState] = useState(0);
  const chartData = useChartData(); // Stable reference
  
  return (
    <>
      <button onClick={() => setUnrelatedState(s => s + 1)}>
        Unrelated Update
      </button>
      {/* Won't re-render when unrelatedState changes */}
      <ExpensiveChart data={chartData} />
    </>
  );
}

3. Stabilize Object/Array Props

// Problem: New object every render
function Parent() {
  return <Child config={{ theme: 'dark', size: 'large' }} />;
  // New object reference every time
}

// Solution: useMemo
function Parent() {
  const config = useMemo(
    () => ({ theme: 'dark', size: 'large' }),
    [] // Never changes
  );
  return <Child config={config} />;
}

// Alternative: Move outside component
const CONFIG = { theme: 'dark', size: 'large' };

function Parent() {
  return <Child config={CONFIG} />;
}

4. Virtualize Long Lists

// React reconciles 10,000 DOM nodes
function HugeList({ items }) {
  return (
    <div>
      {items.map(item => <Item key={item.id} {...item} />)}
    </div>
  );
}

// React only reconciles ~20 visible items
import { FixedSizeList } from 'react-window';

function VirtualizedList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={50}
    >
      {({ index, style }) => (
        <div style={style}>
          <Item {...items[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

5. Keep Component Tree Stable

// Component type changes destroy state
function Form({ step }) {
  if (step === 1) return <PersonalInfo />;
  if (step === 2) return <AddressInfo />;
  if (step === 3) return <PaymentInfo />;
}
// User loses all form input when step changes

// Conditional rendering preserves structure
function Form({ step }) {
  return (
    <>
      {step === 1 && <PersonalInfo />}
      {step === 2 && <AddressInfo />}
      {step === 3 && <PaymentInfo />}
    </>
  );
}
// Each component maintains state when hidden

Common Misconceptions

Myth: "Virtual DOM makes React fast"

Reality: Virtual DOM is overhead. React is fast because it minimizes expensive real DOM operations through smart diffing. The Virtual DOM is the means, not the reason.

Myth: "React re-renders everything on every state change"

Reality: React only re-renders components affected by the state change, and even then, only updates DOM nodes that actually changed.

Myth: "More keys = better performance"

Reality: Keys only matter for lists. Adding them elsewhere does nothing.

// Pointless
<div key="wrapper">
  <Header key="header" />
  <Main key="main" />
</div>

// Meaningful
<ul>
  {items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>

Myth: "React.memo makes everything faster"

Reality: Memoization has overhead. Only use it for components that render often with same props or have expensive rendering.

// Wasteful memoization
const SimpleText = React.memo(({ text }) => <p>{text}</p>);
// Rendering <p> is cheaper than the memo comparison

// Worthwhile memoization
const ComplexChart = React.memo(({ data }) => {
  // 50ms of D3 rendering
  return <svg>...</svg>;
});

Debugging Tools

Chrome DevTools and React DevTools let you visualize reconciliation.

React DevTools Profiler:

  1. Open React DevTools

  2. Go to "Profiler" tab

  3. Click record

  4. Interact with your app

  5. Stop recording

You'll see which components rendered, how long each render took, why each component rendered (which props/state changed), and a flamegraph of the component tree.

Enable "Highlight updates when components render" in React DevTools settings. Your components will flash when they re-render.

Server Components

React's evolution continues. Server Components flip the model:

Traditional React: Server sends JavaScript → Browser executes → Virtual DOM created → DOM updated

Server Components: Server creates Virtual DOM → Sends serialized tree → Client hydrates → Much smaller bundle

This means zero JavaScript for static components, faster initial load, and direct database access in components.

// This runs on the server
async function UserProfile({ id }) {
  const user = await db.users.findById(id); // Direct DB access
  
  return (
    <div>
      <h1>{user.name}</h1>
      <ClientButton /> {/* Only this ships JS to browser */}
    </div>
  );
}

But the core reconciliation algorithm? Still the same diffing we explored.

You've journeyed deep into React's core. You now understand why Virtual DOM exists, how reconciliation works, the diffing algorithm's two key assumptions, how Fiber enables concurrent rendering, when and why to optimize, and common myths.

This knowledge transforms how you write React. You'll intuitively write performant code, debug issues faster, make better architectural decisions, and appreciate why certain patterns exist.

The next time you write <Component key={item.id}>, you'll know exactly what React does with that key. You're no longer just using React—you understand it.

Resources

Loading comments...

Share this article