How React Works Internally: The Fiber Architecture, Reconciliation Algorithm, and Concurrent Rendering from setState to DOM Update
A deep dive into React's internal architecture covering the Fiber tree data structure, the O(n) reconciliation algorithm, hook storage as linked lists, concurrent rendering with lanes and priority scheduling, and the complete lifecycle of a state update from setState through to DOM commit.
Most engineers who work with React daily have a rough mental model: state changes, React re-renders, the DOM updates. That model is enough to ship features. It is not enough to debug subtle concurrent mode issues, understand why certain hook patterns cause stale closures, or make informed decisions about when React’s scheduler is your ally or your enemy. This article goes deeper.
The Problem with “Virtual DOM” as a Mental Model
The term “virtual DOM” is technically accurate but conceptually misleading. It implies that React maintains a JavaScript copy of the DOM and diffs it on every render. The reality is more sophisticated. React maintains a tree of Fiber nodes, each representing a unit of work. That work can be paused, resumed, prioritized, and abandoned. The DOM is only one possible output target. Understanding Fiber is prerequisite to understanding everything else.
The Fiber Data Structure
A Fiber node is a plain JavaScript object. Every component instance, DOM element, and context provider in your tree corresponds to one. Here is a simplified version of the shape React uses internally:
type Flags = number; // bit field for side effects
interface Fiber {
// Identity
tag: WorkTag; // FunctionComponent = 0, HostComponent = 5, etc.
key: string | null;
type: any; // the function or class, or a string like "div"
stateNode: any; // DOM node for host components, class instance for class components
// Tree structure
return: Fiber | null; // parent
child: Fiber | null; // first child
sibling: Fiber | null; // next sibling (singly linked list)
index: number;
// Props and state
pendingProps: any;
memoizedProps: any;
memoizedState: any; // for function components: the first hook in the linked list
// Update queue
updateQueue: UpdateQueue<any> | null;
// Effect tracking
flags: Flags; // what side effects this node has
subtreeFlags: Flags; // aggregated flags from subtree (React 18+)
deletions: Array<Fiber> | null;
// Alternate
alternate: Fiber | null; // points to the work-in-progress counterpart
// Scheduling
lanes: Lanes;
childLanes: Lanes;
}
The alternate field is central to how React avoids unnecessary allocations. React maintains two trees at all times: the “current” tree (what is on screen) and the “work-in-progress” tree (what is being computed). These two trees mirror each other via alternate pointers. When a render completes, the work-in-progress tree becomes current. On the next render, the old current tree becomes the work-in-progress tree. React reuses and mutates these objects rather than creating fresh ones on every render.
Hooks as a Linked List
When a function component renders, each hook call appends a node to a singly linked list stored on the Fiber’s memoizedState field. This is why hooks cannot be called conditionally: the reconciler identifies hooks purely by their position in the list.
interface Hook {
memoizedState: any; // the hook's current value
baseState: any;
baseQueue: Update<any> | null;
queue: UpdateQueue<any> | null;
next: Hook | null; // next hook in this component's list
}
// For useState, memoizedState is the state value itself.
// For useEffect, memoizedState is an Effect object:
interface Effect {
tag: HookFlags;
create: () => (() => void) | void; // the effect function
destroy: (() => void) | void; // the cleanup function
deps: Array<any> | null;
next: Effect | null;
}
When you call useState(0) in a component that is rendering for the first time (“mount”), React creates a Hook node with memoizedState = 0 and appends it to the list. On subsequent renders (“update”), React walks the existing list in order, returning the current value from memoizedState for each position. Call the same component with a useState inside an if block and the positions shift, which is why React throws an error rather than silently returning wrong state.
The Two-Phase Rendering Model
React’s rendering splits into two phases with fundamentally different rules.
Render phase (also called “reconciliation”): React calls your component functions, runs the reconciler algorithm, and computes what changed. This phase is pure: no DOM mutations happen here. Because it is pure, React can abandon and restart this work at any time. In concurrent mode, the scheduler can pause this phase mid-tree and resume it later.
Commit phase: React applies the computed changes to the DOM. This phase is synchronous and cannot be interrupted. It runs in three sub-passes:
BeforeMutation: callsgetSnapshotBeforeUpdateon class components and schedulesuseLayoutEffectcleanups.Mutation: actually mutates the DOM: insertions, updates, deletions.Layout: firesuseLayoutEffecteffects synchronously, after DOM mutations but before the browser paints.
After the commit phase, React schedules useEffect callbacks to run asynchronously, after the browser has painted. This is why useLayoutEffect is appropriate for measuring DOM geometry (you need to read the DOM before the user sees it) while useEffect is appropriate for anything that does not need synchronous post-mutation access.
The Reconciliation Algorithm
Diffing two arbitrary trees is O(n³) in the general case. React gets to O(n) by applying two heuristics that hold for virtually all practical UI trees.
Heuristic 1: Elements of different types produce different trees. If the old node is a div and the new node is a span, React tears down the entire old subtree and builds the new one from scratch. It does not attempt to match children.
Heuristic 2: Keys allow stable identity across list reorders. Without keys, React matches children by index. With keys, React can detect that a node moved rather than was deleted and recreated.
The diffing algorithm walks the Fiber tree depth-first. For each node, React runs reconcileChildFibers (or mountChildFibers on first render). Here is a stripped-down version of what that looks like for a list of children:
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
): Fiber | null {
// First pass: match by index while both old and new lists have items
let oldFiber = currentFirstChild;
let newIdx = 0;
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const sameType =
oldFiber.type === newChild.type && oldFiber.key === (newChild.key ?? null);
if (!sameType) {
break; // stop index-matching, fall through to key-based pass
}
const newFiber = useFiber(oldFiber, newChild.props); // reuse the Fiber
newFiber.return = returnFiber;
// ... link into the result list
oldFiber = oldFiber.sibling;
}
// Second pass: remaining items use a key map for O(1) lookup
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const matchingFiber = existingChildren.get(newChild.key ?? newIdx);
if (matchingFiber) {
existingChildren.delete(newChild.key ?? newIdx);
const newFiber = useFiber(matchingFiber, newChild.props);
// ... link into result list
} else {
// create a new Fiber from scratch
}
}
// Any remaining entries in existingChildren are deletions
existingChildren.forEach(child => deleteChild(returnFiber, child));
return resultingFirstChild;
}
Nodes that survive diffing get a reused Fiber with updated pendingProps. Nodes that are new get freshly created Fibers. Nodes that disappeared get marked with a Deletion flag and collected in returnFiber.deletions.
Concurrent Rendering and Lanes
Before React 18, all rendering was synchronous. A large tree update would block the main thread for the entire duration, causing jank. Concurrent rendering breaks this by making the render phase interruptible.
The scheduler uses a priority system called “lanes.” Each lane is a bit in a 31-bit integer, which lets React track and combine multiple priorities with bitwise operations.
type Lanes = number;
type Lane = number;
const SyncLane: Lane = 0b0000000000000000000000000000001;
const InputContinuousLane: Lane = 0b0000000000000000000000000000100;
const DefaultLane: Lane = 0b0000000000000000000000000010000;
const TransitionLane1: Lane = 0b0000000000000000000000001000000;
const IdleLane: Lane = 0b0100000000000000000000000000000;
When you call setState inside a click handler, React assigns SyncLane. When you call startTransition(() => setState(...)), React assigns a TransitionLane. The scheduler processes higher-priority lanes first. If a high-priority update arrives while a low-priority render is in progress, React can abandon the work-in-progress tree and restart with the new high-priority update at the top.
This is the mechanism behind startTransition. Marking a state update as a transition tells React it is acceptable to keep showing stale UI while the expensive render runs in the background. If the user interacts again before that background render finishes, React throws away the partial work and starts over with the new input.
The Complete Lifecycle of a setState Call
Putting it all together, here is what happens when you call setState in a function component:
1. Dispatch an update. React creates an Update object with the new state value (or updater function) and the current execution lane. This update gets enqueued on the Fiber’s updateQueue.
interface Update<S> {
lane: Lane;
action: S | ((prevState: S) => S);
next: Update<S> | null;
}
2. Schedule work. React calls scheduleUpdateOnFiber, which walks up the Fiber tree to the root, marking each Fiber’s childLanes along the way. This is how React knows which subtrees contain pending work without visiting every node. Then it calls ensureRootIsScheduled, which registers a task with the scheduler at the appropriate priority.
3. Render phase begins. The scheduler calls performConcurrentWorkOnRoot (or performSyncWorkOnRoot for sync lanes). React begins a depth-first traversal of the Fiber tree, calling beginWork on each node. For function components, beginWork calls the component function. Hooks execute, reading from and writing to the hook linked list on the Fiber. The output (React elements) is diffed against the current tree via reconcileChildFibers. The reconciler produces a work-in-progress Fiber for each node in the output, flagging nodes with side effects (Placement, Update, Deletion, etc.).
4. Completing the work. After beginWork visits a node, React calls completeWork on the way back up the tree. For host components (DOM elements), completeWork creates or updates the actual DOM node in memory (not yet attached). It also propagates subtreeFlags upward so the commit phase can skip subtrees with no pending effects.
5. Commit phase. Once the entire work-in-progress tree is complete, React calls commitRoot. The three sub-passes run synchronously: before-mutation, mutation (DOM changes are applied), and layout (useLayoutEffect fires). The work-in-progress tree is now current.
6. Passive effects. After the browser paints, React flushes useEffect cleanup and setup functions for any Fibers flagged with Passive effects.
Tradeoffs Table
| Dimension | React (Fiber) | Vue 3 (Reactivity) | Svelte (Compiled) | SolidJS (Fine-grained) |
|---|---|---|---|---|
| Update granularity | Component-level re-render | Component-level, with fine-grained reactive deps | Statement-level, compiled to imperative DOM ops | Signal-level, no re-render of components |
| Bundle size | ~45KB runtime (React + ReactDOM) | ~34KB runtime | Near-zero runtime, logic compiled away | ~7KB runtime |
| Runtime overhead | Fiber traversal + diffing per update | Proxy-based dependency tracking | Minimal: compiled to direct DOM assignments | Minimal: reactive graph executes only changed nodes |
| Mental model complexity | High: Fiber, reconciliation, hooks rules, concurrent semantics | Medium: reactive refs, watchers, computed values | Low: write like HTML+JS, compiler handles it | Medium: signals and derived computations, no component boundaries matter |
| Concurrent rendering | Yes: lanes, startTransition, useDeferredValue, Suspense | No: synchronous reactivity model | No: no runtime scheduler | No: fine-grained updates make coarse scheduling unnecessary |
| Ecosystem maturity | Largest ecosystem, de facto industry standard | Large, especially in Asia and enterprise Europe | Growing, strong for content sites and smaller apps | Small but active, used for performance-critical UIs |
The comparison reveals a real tension. React’s Fiber architecture is the most sophisticated runtime in the group, and that sophistication buys genuine capability (concurrent rendering, Suspense, streaming SSR). It also costs bundle size, runtime overhead, and considerable mental model complexity. SolidJS eliminates the overhead by eliminating the abstraction: there is no virtual tree to diff because updates go directly to the reactive graph. Svelte eliminates the runtime by moving the work to compile time. Vue sits between React and Svelte, using a proxy-based reactive system that tracks dependencies more precisely than React’s component-level model but still ships a runtime.
For most applications, the ecosystem and hiring market around React outweigh these differences. For latency-critical, animation-heavy UIs, SolidJS’s fine-grained model avoids unnecessary work by construction. For content-driven sites where JavaScript bundle size matters, Svelte is a strong choice.
Production Considerations
Avoid object identity churn in props. Because React re-renders at the component level, passing a new object literal as a prop on every parent render causes the child to re-render even if the data is semantically identical. useMemo and useCallback exist to stabilize referential identity, but they should be applied after profiling, not preemptively.
Understand what startTransition actually guarantees. It does not make a render faster. It marks it as lower priority so that more urgent work (input events, animations) can interrupt it. If your transition update is the only thing happening, it runs at the same speed as a normal update.
Profile with the React DevTools Profiler, not just the browser profiler. The browser profiler shows you JavaScript execution time. The React Profiler shows you which components re-rendered, why they re-rendered, and how long their render took. A component that renders in 2ms but renders 200 times per second is a problem that only the React Profiler surfaces clearly.
useLayoutEffect reads are synchronous with the DOM mutation. If you need to measure a DOM node’s size or position after a state change and before the browser paints, useLayoutEffect is correct. Using useEffect for the same purpose causes a visible flicker because the browser paints the intermediate state before you can correct it.
Concurrent mode does not automatically make everything faster. It makes the application more responsive under load by prioritizing urgent updates. A single expensive synchronous component that blocks for 300ms will still block for 300ms. Breaking that component into smaller pieces, virtualizing long lists, or using useDeferredValue to defer the expensive part are the actual solutions.
The Insight
React’s Fiber architecture is a scheduling runtime disguised as a UI library. The virtual DOM was always the wrong mental model. The right model is a work queue with priorities: each component is a unit of work, each render is a task, and the scheduler decides what to run and when. That model explains why hooks have ordering rules (position in a linked list), why startTransition works the way it does (lane priority), why the commit phase cannot be interrupted (atomicity guarantee for the DOM), and why Suspense is possible at all (the render phase can yield mid-tree when it encounters a pending promise, and resume when the promise resolves).
Understanding the machinery does not mean you need to think about Fibers when writing components. It means you have an accurate model for the moments when React’s behavior surprises you, and you can diagnose rather than guess.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.