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:
Browser recalculates styles (reflow)
Repaints the affected area
Triggers layout shifts
Potentially affects other elements
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:
<div className="profile">- Same type, same props → SKIP<h2>John Doe</h2>- Same type, same text → SKIP<button>- Same type, different text → UPDATE TEXT ONLY<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 />subtreeThis 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:
Header: Checks if props changed → No → Skips re-render (if memoized)
Counter: Checks if props changed → Yes (
countprop) → Re-rendersFooter: 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:
Immediate: User input, clicking, typing
User-blocking: Animations, scrolling
Normal: Data fetching, network responses
Low: Analytics, logging
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:
User types "r" → Input updates instantly (high priority)
React starts calculating expensive search results (low priority)
User types "e" → React interrupts the search calculation
Input updates to "re" instantly
React restarts search calculation from scratch
User types "a" → Process repeats
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:
Open React DevTools
Go to "Profiler" tab
Click record
Interact with your app
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...