React Internals & Architecture
This guide covers the internal mechanisms of React: how the scheduler works, reconciliation, rendering phases, event handling, hydration, and modern features like the React Compiler and Server Components.
Fiber Architecture #
React uses a Fiber architecture instead of a traditional call stack. Fibers are lightweight JavaScript objects representing work to be done.
What is a Fiber? #
A fiber is a data structure with these properties:
{
type: 'div', // Component type (string for DOM, function for custom)
props: { children: [...] }, // Props passed to component
key: 'unique-key', // Key for list reconciliation
parent: parentFiber, // Link to parent fiber
child: childFiber, // Link to first child
sibling: siblingFiber, // Link to next sibling
alternate: previousFiber, // Link to previous version (for diffing)
effectTag: 'PLACEMENT', // What needs to be done (UPDATE, PLACEMENT, DELETION)
hooks: [hook1, hook2], // Hooks state for function components
_owner: ownerComponent, // Who rendered this fiber
index: 0, // Position in parent's children
}Why Fibers? #
Before fibers, React used a synchronous recursive rendering algorithm. This meant:
- Once rendering started, it couldn’t be interrupted
- Long renders would block the main thread, freezing the UI
- No priority system—all updates were equally important
Fibers enable:
- Incremental rendering — Work can be split across multiple frames
- Prioritization — Urgent updates (input) interrupt low-priority ones (data fetching)
- Error boundaries — Errors can be caught and handled gracefully
- Suspense — Rendering can pause and resume
Work Loop #
React’s scheduler continuously processes fibers:
// Simplified work loop
function workLoop() {
while (nextUnitOfWork && shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork === null) {
// All work done, commit phase
commitRoot();
} else {
// Schedule next batch
scheduleCallback(workLoop);
}
}
function performUnitOfWork(fiber) {
// 1. Render/reconcile this fiber
// 2. Return next fiber to process
// 3. Or return null if done
}Scheduler #
The React Scheduler manages when work gets executed. It’s crucial for keeping the UI responsive.
Priority Levels #
React has multiple priority levels:
// From react-dom package
ImmediatePriority // 1 - Sync (blocking)
UserBlockingPriority // 2 - High (user interaction)
NormalPriority // 3 - Medium (data fetching)
LowPriority // 4 - Low (non-urgent)
IdlePriority // 5 - Idle (lowest)
Scheduling Work #
// High priority (e.g., input)
import { unstable_scheduleCallback, unstable_UserBlockingPriority } from 'scheduler';
unstable_scheduleCallback(
unstable_UserBlockingPriority,
() => {
// This runs soon
}
);
// Low priority (e.g., data fetching)
import { unstable_LowPriority } from 'scheduler';
unstable_scheduleCallback(
unstable_LowPriority,
() => {
// This can wait
}
);Time Slicing #
React yields the main thread every ~5ms to let the browser:
- Handle user input
- Run other scripts
- Repaint the screen
// Simplified time slicing
const timeRemaining = () => {
const now = performance.now();
const frameDeadline = frameDue; // ~5ms per frame
return frameDeadline - now;
};
function workLoop() {
while (nextUnitOfWork && timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// More work, schedule next frame
scheduleCallback(workLoop);
} else {
// Done, commit
commitRoot();
}
}useTransition Internals #
useTransition leverages the scheduler:
const [isPending, startTransition] = useTransition();
startTransition(() => {
// This callback runs with LowPriority
// Can be interrupted by input (UserBlockingPriority)
setSlowState(newValue);
});Internally, React schedules the transition callback with low priority, allowing high-priority updates to jump the queue.
Event Delegation #
React doesn’t attach event listeners to individual elements. Instead, it uses event delegation—one listener at the root captures all events.
How It Works #
// React's simplified event delegation
const rootElement = document.getElementById('root');
// One listener for all events
rootElement.addEventListener('click', handleDOMEvent, true); // Capture phase
function handleDOMEvent(nativeEvent) {
// Find the React component that should handle this
const fiberNode = nativeEvent.target._reactInternalFiber;
// Traverse up the fiber tree calling event handlers
let current = fiberNode;
while (current) {
if (current.memoizedProps.onClick) {
current.memoizedProps.onClick(syntheticEvent);
}
current = current.parent;
}
}Why Event Delegation? #
- Memory efficient — One listener instead of hundreds
- Dynamic elements — Newly mounted components automatically work
- Easier cleanup — Single listener to remove
- Control — React can intercept and batch events
Event System Changes (React 17+) #
React 17 changed event delegation to attach listeners to the root component container instead of document. This allows multiple React apps on the same page without conflicts.
// React 16 - listeners on document
document.addEventListener('click', handleClick);
// React 17+ - listeners on root container
rootContainer.addEventListener('click', handleClick);Synthetic Events #
React wraps native browser events into Synthetic Events—a cross-browser API that normalizes differences.
Synthetic Event Structure #
function handleClick(event) {
console.log(event); // SyntheticEvent, not native MouseEvent
console.log(event.nativeEvent); // Access native browser event
// Common properties (normalized across browsers)
event.type; // 'click'
event.target; // The element clicked
event.currentTarget; // The element with the handler
event.preventDefault(); // Prevent default behavior
event.stopPropagation(); // Stop bubbling
// Browser-specific properties might differ
}Event Pooling (React 16) #
In React 16, SyntheticEvents were pooled for performance. The same object was reused for all events:
// React 16
function handleClick(event) {
console.log(event.type); // 'click'
setTimeout(() => {
console.log(event.type); // null! Event was pooled
}, 0);
}This was removed in React 17+ because modern browsers are efficient enough.
Asynchronous Event Access #
In React 17+, you can safely access events asynchronously:
function handleChange(event) {
// Safe to access asynchronously
setTimeout(() => {
console.log(event.target.value); // Works in React 17+
}, 0);
}Batching #
Batching means React groups multiple state updates together and re-renders only once per batch.
Automatic Batching (React 18+) #
React 18 automatically batches updates:
function App() {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
function handleClick() {
setCount(c => c + 1); // Update 1
setName('Alice'); // Update 2
// React batches both updates
// Single re-render with both changes
}
return <button onClick={handleClick}>Update</button>;
}Before React 18, updates in event handlers were batched, but async code wasn’t:
// React 16-17: TWO re-renders
function handleClick() {
setCount(c => c + 1); // Re-render 1
setTimeout(() => {
setName('Bob'); // Re-render 2 (not batched)
}, 0);
}
// React 18: ONE re-render (batched automatically)
Manual Batching #
In rare cases, you can prevent batching with flushSync:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1); // Re-render immediately
});
setName('Charlie'); // Batched with next update
}Why Batching? #
- Performance — Fewer re-renders
- Consistency — State is unified during render
- Predictability — Multiple updates see each other
Render Phase #
The render phase is where React calculates what needs to change. It’s pure and has no side effects.
Render Phase Steps #
// Simplified render phase
function renderPhase(fiber) {
// 1. Call function component or class render method
const newProps = fiber.props;
const output = fiber.type(newProps); // Or this.render() for class
// 2. Compare with previous version (reconciliation)
const oldFiber = fiber.alternate;
if (shouldUpdate(oldFiber, fiber)) {
fiber.effectTag = 'UPDATE';
}
// 3. Recursively process children
if (output.children) {
const childFiber = createFiber(output.children[0]);
fiber.child = childFiber;
renderPhase(childFiber);
}
}Important: Render Phase is Pure #
The render phase must not have side effects:
// GOOD: Pure
function MyComponent() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}
// BAD: Side effect in render phase
function BadComponent() {
fetch('/api/data'); // Side effect! Called multiple times
return <div>...</div>;
}
// GOOD: Side effect in useEffect (commit phase)
function GoodComponent() {
useEffect(() => {
fetch('/api/data');
}, []);
return <div>...</div>;
}Why? React may call render multiple times (for Suspense, Error Boundaries, concurrent features), so side effects shouldn’t happen here.
Render Phase Interruption #
During the render phase, React can pause and resume:
// High-priority update interrupts render
startTransition(() => {
setSlowData(data); // Low-priority, causes long render
});
// User types (UserBlockingPriority)
setSearchQuery('text'); // Interrupts the low-priority render
// React discards the render work and starts over with new input
This is only safe during render phase because no DOM changes have occurred yet.
Commit Phase #
The commit phase applies changes to the DOM and runs side effects. It’s synchronous and uninterruptible.
Commit Phase Steps #
// Simplified commit phase
function commitRoot(fiber) {
// 1. Commit all DOM changes
commitAllWork(fiber);
// 2. Run layout effects
commitLayoutEffects(fiber);
// 3. Paint to screen
// (Browser repaints)
// 4. Run passive effects (useEffect)
flushPassiveEffects();
}
function commitAllWork(fiber) {
if (fiber.effectTag === 'PLACEMENT') {
insertDOM(fiber);
} else if (fiber.effectTag === 'UPDATE') {
updateDOM(fiber);
} else if (fiber.effectTag === 'DELETION') {
removeDOM(fiber);
}
// Recursively commit children
if (fiber.child) commitAllWork(fiber.child);
if (fiber.sibling) commitAllWork(fiber.sibling);
}Order of Effects #
- Render phase — Calculate changes (can be interrupted)
- Commit phase begins:
- Mutations (DOM updates) — Synchronous
useLayoutEffectruns — Synchronous (before paint)- Browser paints screen
useEffectruns — Asynchronous (after paint)
function Component() {
useLayoutEffect(() => {
console.log('3. Runs after DOM updated, before paint');
}, []);
useEffect(() => {
console.log('4. Runs after paint (async)');
}, []);
console.log('1. Render phase');
return <div>Content</div>;
}
// Order: 1 → 2 (commit mutations) → 3 (useLayoutEffect) → paint → 4 (useEffect)
Why Separate Phases? #
- Render phase is interruptible — React can prioritize urgent updates
- Commit phase is synchronous — Ensures DOM consistency and predictable side effects
Reconciliation #
Reconciliation (or “diffing”) is how React determines which DOM elements changed.
Reconciliation Algorithm #
React compares fibers using these rules:
function canReuseElement(oldFiber, newProps) {
// Rule 1: Same type?
if (oldFiber.type !== newProps.type) return false;
// Rule 2: Same key?
if (oldFiber.key !== newProps.key) return false;
// If both rules pass, this fiber can be reused
return true;
}Key Importance #
Keys are critical for reconciliation, especially in lists:
// WITHOUT keys - elements can get mixed up
function List({ items }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item.name}</li> // BAD: Using index as key
))}
</ul>
);
}
// WITH keys - elements correctly matched
function List({ items }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li> // GOOD: Stable unique key
))}
</ul>
);
}Why this matters:
// Initial: [{ id: 1, name: 'Alice', input: 'text1' }]
// After insert: [{ id: 2, name: 'Bob' }, { id: 1, name: 'Alice', input: 'text1' }]
// WITHOUT key (uses index):
// Old: <li key={0}>Alice <input value="text1" /></li>
// New: <li key={0}>Bob <input /></li> ← Reuses same element, wrong input!
// → Input value disappears
// WITH key (uses id):
// Old: <li key={1}>Alice <input value="text1" /></li>
// New: <li key={2}>Bob</li> ← Creates new element
// New: <li key={1}>Alice <input value="text1" /></li> ← Reuses same element, input preserved
Heuristics #
React uses heuristics for fast diffing (O(n) instead of O(n³)):
- Different element types → Rebuild the subtree
- Same element type → Compare props
- Lists → Match by key
// Heuristic 1: Different type = rebuild
<div>Content</div>
<span>Content</span> // Completely new, old div destroyed
// Heuristic 2: Same type = compare props
<Component color="red" />
<Component color="blue" /> // Reuse, update color prop
// Heuristic 3: Lists = match by key
[<li key="a">A</li>, <li key="b">B</li>]
[<li key="b">B</li>, <li key="a">A</li>] // Correct order, no re-renderHydration #
Hydration is the process of attaching React to server-rendered HTML.
Server-Side Rendering (SSR) #
- Server renders React component to HTML string
- HTML sent to browser
- Browser displays HTML immediately
- React hydrates (attaches interactivity)
// Server
import { renderToString } from 'react-dom/server';
const html = renderToString(<App />);
res.send(html);
// Client
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);Hydration Mismatch #
The server-rendered HTML must match what React expects, or hydration fails:
// Server: generates random ID
<div id={Math.random()}>Content</div>
// Client: generates different random ID
// → Hydration mismatch warning
Solutions:
// Use useId hook (generates same ID on server and client)
import { useId } from 'react';
function Component() {
const id = useId();
return <div id={id}>Content</div>; // Same on both
}
// Or use static content
<div id="known-id">Content</div>Selective Hydration #
React 18 can hydrate parts of the page progressively:
// App shell hydrates immediately
<Header />
{/* This hydrates when it becomes visible */}
<Suspense fallback={<Skeleton />}>
<Comments />
</Suspense>React Compiler #
The React Compiler (experimental, coming in React 19) automatically optimizes React code by analyzing data flow and memoizing values.
What It Does #
The compiler automatically:
- Memoizes expensive computations
- Memoizes functions
- Optimizes component renders
- Removes unnecessary re-renders
Before Compiler #
You had to manually memoize:
// Manual memoization
function Parent() {
const [count, setCount] = useState(0);
const expensiveValue = useMemo(() => {
return complexCalculation(count);
}, [count]);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return <Child value={expensiveValue} onClick={handleClick} />;
}With Compiler #
The compiler does it automatically:
// Same code, compiler automatically optimizes
function Parent() {
const [count, setCount] = useState(0);
const expensiveValue = complexCalculation(count);
const handleClick = () => {
setCount(c => c + 1);
};
return <Child value={expensiveValue} onClick={handleClick} />;
}
// Compiler transforms to:
// - Memoize expensiveValue based on count
// - Memoize handleClick
// - Avoid re-rendering Child unnecessarily
Compiler Fundamentals #
The compiler:
- Analyzes how data flows through components
- Identifies what depends on what
- Injects memoization automatically
- Prevents unnecessary re-renders
This enables developers to write simpler code without sacrificing performance.
React Server Components (RSC) #
React Server Components (RSC) run on the server, not the client. They enable rendering without JavaScript overhead.
Key Differences #
| Feature | Client Component | Server Component |
|---|---|---|
| Runs on | Browser | Server |
| Can use hooks | Yes | No |
| Can access secrets | No | Yes |
| Can access databases | No (via API) | Yes (direct) |
| Bundle size | Increases | No increase |
| Interactivity | Yes | No |
Server Component Example #
// app/PostList.jsx - Server Component (default in Next.js 13+)
export default async function PostList() {
// Direct database access
const posts = await db.posts.findAll();
// No JavaScript sent to client
return (
<ul>
{posts.map(post => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</li>
))}
</ul>
);
}Client Component (Mark with ‘use client’) #
// app/SearchBox.jsx - Client Component
'use client'; // This directive marks the component as client-only
import { useState } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}Composition #
Server and client components work together:
// app/page.jsx - Server Component
import PostList from './PostList'; // Server Component
import SearchBox from './SearchBox'; // Client Component
export default function Page() {
return (
<div>
{/* Run on server */}
<SearchBox /> {/* Marked with 'use client' */}
<PostList /> {/* Server component, no JS */}
</div>
);
}Rules:
- Server components can import client components
- Client components cannot import server components
- Pass server data to client via props
// app/page.jsx - Server Component
import ClientComponent from './ClientComponent'; // Client Component
async function getData() {
const data = await db.fetch();
return data;
}
export default async function Page() {
const data = await getData();
return (
// Pass server data to client component
<ClientComponent data={data} />
);
}
// app/ClientComponent.jsx - Client Component
'use client';
export default function ClientComponent({ data }) {
return <div>{data.title}</div>;
}Benefits #
- Smaller bundles — Server logic never shipped to client
- Direct database access — No API layer needed
- Security — Secrets stay on server
- Performance — More work on server, less on client
Additional React Internals Topics #
Virtual DOM #
The Virtual DOM is an in-memory representation of the real DOM. React uses it for diffing.
// Virtual DOM (React fiber tree)
{
type: 'div',
props: { className: 'container' },
children: [
{ type: 'h1', children: 'Title' },
{ type: 'p', children: 'Content' }
]
}
// Real DOM
<div class="container">
<h1>Title</h1>
<p>Content</p>
</div>React renders to Virtual DOM, diffs it, then updates the real DOM.
Hook Implementation #
Hooks work by maintaining a linked list of hook instances per component:
// Simplified hook implementation
let componentHooks = [];
let currentHookIndex = 0;
function useState(initialValue) {
const index = currentHookIndex;
componentHooks[index] = componentHooks[index] || initialValue;
const setState = (value) => {
componentHooks[index] = value;
scheduleRender();
};
const state = componentHooks[index];
currentHookIndex++;
return [state, setState];
}
function render() {
currentHookIndex = 0; // Reset for next render
return MyComponent();
}Why hooks must be in order:
- Hooks are identified by position, not name
- Calling hooks conditionally breaks the index mapping
Concurrent Mode #
Concurrent mode allows React to work on multiple tasks in parallel (not literally parallel, but interruptible):
// Without Concurrent Mode: blocking render
longRender(); // Freezes UI
// With Concurrent Mode: interruptible render
startTransition(() => {
longRender(); // Can be interrupted by user input
});Strict Mode #
<StrictMode> intentionally double-invokes functions to detect bugs:
function Component() {
console.log('render'); // Called twice in StrictMode (dev only)
return <div>Content</div>;
}
// Output in development with StrictMode:
// render
// render
This helps catch:
- Side effects in render
- Memory leaks in useEffect
- Unexpected state mutations
Interview Tips #
Common React Internals Questions:
“How does React’s reconciliation work?”
- Explain fiber architecture, diffing algorithm, and key importance
“What’s the difference between render and commit phases?”
- Render is interruptible and pure; commit is synchronous with side effects
“How does event delegation work in React?”
- Single listener at root, synthetic events for normalization
“Why can’t you call hooks conditionally?”
- Hooks are identified by call order, conditional calls break the index
“What’s hydration?”
- Attaching React to server-rendered HTML to add interactivity
“How does batching improve performance?”
- Multiple state updates trigger single render pass
“What are Server Components and why use them?”
- Run on server, reduce bundle size, enable direct DB access
“How does the scheduler prioritize work?”
- Different priority levels allow important updates to interrupt others