Introduction
React Fiber is the reconciliation engine that powers React 16 and beyond. It's the complete rewrite of React's core algorithm that enables features like concurrent rendering, Suspense, and priority-based updates. Understanding Fiber isn't just academic — it explains why React behaves the way it does, why certain patterns are faster than others, and how to write components that leverage React's scheduling capabilities.
Before Fiber, React used a recursive reconciliation algorithm that couldn't pause, abort, or prioritize work. Once rendering started, it had to complete synchronously. Fiber changed this by introducing a linked-list tree structure that allows React to pause work, resume it later, and abandon low-priority updates when higher-priority ones arrive.
The Problem Fiber Solves
Pre-Fiber Reconciliation
In React 15 and earlier, reconciliation was a recursive process:
// Simplified pre-Fiber reconciliation
function reconcileChildren(element) {
// This runs recursively — cannot be interrupted
const children = element.props.children;
for (const child of children) {
updateDOM(child); // Block until done
reconcileChildren(child); // Then recurse into children
}
}This approach has a critical limitation: if the component tree is large, reconciliation blocks the main thread for potentially hundreds of milliseconds. During this time, the browser can't respond to user input — clicks, scrolls, and animations freeze.
The 16ms Budget
Browsers render at 60fps, meaning each frame has a 16ms budget. If JavaScript runs for longer than 16ms, frames are dropped and the UI feels janky. With large component trees, React's recursive reconciliation could easily exceed this budget. A tree with 10,000 components might take 50-100ms to reconcile, freezing the interface for three to six frames. Users perceive this as stuttering, laggy input fields, and unresponsive buttons.
Fiber's Solution: Incremental Rendering
Fiber breaks rendering into small units of work that can be started, paused, and resumed:
// Fiber work loop (simplified)
function workLoop(deadline) {
while (nextUnitOfWork && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// Not done — schedule continuation
requestIdleCallback(workLoop);
} else {
// All work done — commit to DOM
commitRoot();
}
}The key insight: React can yield control back to the browser between units of work, allowing the browser to handle high-priority tasks (user input, animations) before React resumes rendering. The shouldYield() function checks how much time has elapsed since the current frame started. If the budget is nearly exhausted, React pauses and lets the browser paint the current frame before resuming.
The Fiber Node Structure
Each React element has a corresponding Fiber node. Fiber nodes form a linked-list tree rather than the recursive tree used in React 15:
interface Fiber {
// Identity
tag: WorkTag; // FunctionComponent, ClassComponent, HostComponent, etc.
type: string | Function | null; // The function/class or DOM tag name
key: string | null;
stateNode: any; // DOM node or class instance
// Tree structure (linked list)
child: Fiber | null; // First child
sibling: Fiber | null; // Next sibling
return: Fiber | null; // Parent fiber
index: number;
// State and props
pendingProps: any;
memoizedProps: any; // Props from last completed render
memoizedState: any; // State from last completed render
updateQueue: UpdateQueue | null; // Queued state updates
// Effects
flags: number; // Placement, Update, Deletion, Passive, etc.
subtreeFlags: number; // Aggregated flags from children
deletions: Fiber[] | null; // Child fibers to delete
// Work scheduling
lanes: Lanes; // Priority lanes for this fiber
childLanes: Lanes; // Priority lanes for children
// Double buffering
alternate: Fiber | null; // The other version of this fiber
}The Linked-List Tree
Unlike the recursive tree structure, Fiber uses a linked-list traversal:
// Traditional tree (recursive)
// A
// / \
// B C
// / \ \
// D E F
// Fiber linked-list traversal
// A → child → B → child → D → sibling → E → return → B → sibling → C → child → F
//
// Traversal order: A, B, D, E, C, F
// Always: visit child first, then sibling, then return to parentThis structure allows React to pause at any node, save its position (the nextUnitOfWork pointer), and resume later — something impossible with the recursive approach. The return pointer lets React walk back up the tree after completing a subtree, enabling the depth-first traversal without a call stack.
Work Tags
Every Fiber node has a tag that tells React how to process it:
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostRoot = 3; // Root of the tree
export const HostComponent = 5; // DOM element (div, span, etc.)
export const HostText = 6; // Text node
export const SuspenseComponent = 13;
export const OffscreenComponent = 19; // Offscreen renderingReact's beginWork function uses the tag to dispatch to the correct handler — calling the function for FunctionComponent, calling render() for ClassComponent, or creating DOM nodes for HostComponent.
The Two Phases
Render Phase (Interruptible)
The render phase creates new Fiber nodes, compares them with previous nodes, and determines what changes need to happen. This phase is pure — it doesn't modify the DOM:
function performUnitOfWork(fiber: Fiber): Fiber | null {
// 1. beginWork — process this fiber
const next = beginWork(fiber);
// 2. Store finished props
fiber.memoizedProps = fiber.pendingProps;
// 3. Return next unit of work
if (next !== null) {
return next; // Child exists — process it
}
// No child — complete this fiber and move to sibling or parent
let current = fiber;
do {
completeWork(current);
const sibling = current.sibling;
if (sibling !== null) {
return sibling; // Process sibling next
}
current = current.return; // Go back to parent
} while (current !== null);
return null; // All work done
}The beginWork function handles the core reconciliation logic for each fiber type:
function beginWork(fiber: Fiber): Fiber | null {
switch (fiber.tag) {
case FunctionComponent: {
const Component = fiber.type;
const nextProps = fiber.pendingProps;
// Reconcile: call the function, get children
const nextChildren = Component(nextProps);
reconcileChildren(fiber, nextChildren);
return fiber.child;
}
case HostComponent: {
const nextProps = fiber.pendingProps;
const nextChildren = nextProps.children;
reconcileChildren(fiber, nextChildren);
return fiber.child;
}
case HostText: {
// Text nodes have no children
return null;
}
}
}The reconcileChildren function is where the diffing algorithm runs. It compares the new children array against the existing child fibers, creating new fibers for additions, updating existing fibers for changes, and marking fibers for deletion when children are removed:
function reconcileChildren(returnFiber: Fiber, newChildren: any) {
if (typeof newChildren === 'string' || typeof newChildren === 'number') {
// Text content — no children to reconcile
return;
}
const newChildrenArray = Array.isArray(newChildren) ? newChildren : [newChildren];
let oldFiber = returnFiber.child;
let newIndex = 0;
let lastPlacedIndex = 0;
// Diff algorithm: match old fibers to new elements
for (; oldFiber !== null && newIndex < newChildrenArray.length; newIndex++) {
const newElement = newChildrenArray[newIndex];
if (oldFiber.index > newIndex) {
// Old fiber is ahead — will be reused later
break;
}
if (isSameType(oldFiber, newElement)) {
// Same type — update in place
const updated = updateSlot(oldFiber, newElement);
lastPlacedIndex = placeChild(updated, lastPlacedIndex);
} else {
// Different type — delete old, create new
deleteChild(returnFiber, oldFiber);
const created = createFiberFromElement(newElement);
lastPlacedIndex = placeChild(created, lastPlacedIndex);
}
oldFiber = oldFiber.sibling;
}
// Handle remaining old fibers (deletion) and new elements (creation)
// ...
}Commit Phase (Synchronous)
The commit phase applies all changes to the DOM. This phase is synchronous and cannot be interrupted — partial updates would create inconsistent UI:
function commitRoot() {
const finishedWork = root.finishedWork;
// 1. Commit mutation effects (DOM changes)
commitMutationEffects(finishedWork);
// 2. Swap trees (double buffering)
root.current = finishedWork;
// 3. Run layout effects (useLayoutEffect)
commitLayoutEffects(finishedWork);
// 4. Schedule passive effects (useEffect) for later
schedulePassiveEffects(finishedWork);
}React 18 splits the commit into three sub-phases:
- Mutation phase — DOM insertions, updates, and deletions happen here
- Layout phase —
useLayoutEffectcallbacks run synchronously after DOM mutations but before the browser paints - Passive phase —
useEffectcallbacks are scheduled asynchronously viaMessageChannel, running after the browser paints
Double Buffering
React maintains two trees: the current tree (what's on screen) and the work-in-progress tree (what's being built). This is similar to double buffering in graphics rendering:
// When a component updates:
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (!workInProgress) {
// First render — create new fiber
workInProgress = createFiber(current.type, pendingProps);
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// Reuse fiber from previous render — reset work fields
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
workInProgress.child = current.child;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
}
return workInProgress;
}When the commit phase completes, React swaps the trees: the work-in-progress becomes the current tree, and the old current tree becomes the next work-in-progress. This atomic swap ensures the user never sees a partially rendered UI. The previous work-in-progress is recycled to avoid garbage collection overhead.
Priority Scheduling with Lanes
React 18 introduced the Lanes model for priority scheduling. Lanes are represented as bitmasks, where each bit represents a priority lane:
// Priority levels (simplified from React source)
const SyncLane = 0b0000000000000000000000000000001;
const InputContinuousLane = 0b0000000000000000000000000000100;
const DefaultLane = 0b0000000000000000000000000010000;
const TransitionLane1 = 0b0000000000000000000000000100000;
const TransitionLane2 = 0b0000000000000000000000001000000;
const IdleLane = 0b0100000000000000000000000000000;The bitmask approach allows React to efficiently check for pending work with bitwise operations:
// Check if there are any pending sync lanes
const hasSyncWork = (pendingLanes & SyncLane) !== 0;
// Check if any transition work is pending
const transitionMask = TransitionLane1 | TransitionLane2 | TransitionLane3;
const hasTransitionWork = (pendingLanes & transitionMask) !== 0;
// Get highest priority lane from pending set
function getHighestPriorityLane(lanes: Lanes): Lane {
return lanes & -lanes; // Isolate lowest set bit
}Higher-priority updates can interrupt lower-priority work:
function handleInput(value) {
// User keystroke — SyncLane, processes immediately, interrupts everything
setInputValue(value);
// Search results — TransitionLane, can be deferred and interrupted
startTransition(() => {
setResults(performSearch(value));
});
}How Lanes Interact with the Work Loop
function ensureRootIsScheduled(root: FiberRoot) {
const nextLanes = getNextLanes(root, NoLanes);
if (nextLanes === NoLanes) {
return; // Nothing to do
}
// Determine callback priority based on lanes
let newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === SyncLane) {
// Schedule synchronously — no delay allowed
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
queueMicrotask(flushSyncCallbacks);
} else {
// Schedule with the Scheduler package
const schedulerPriority = lanesToSchedulerPriority(newCallbackPriority);
scheduleCallback(schedulerPriority, performConcurrentWorkOnRoot.bind(null, root));
}
}When a higher-priority update arrives during a lower-priority render, React checks shouldYieldToHost() in the work loop. If true, React pauses the current work, saves the nextUnitOfWork pointer, and yields to the browser. After processing the urgent input, React resumes from where it left off or restarts if the higher-priority update invalidated the work-in-progress tree.
How useState Works in Fiber
Each Fiber node has a memoizedState that holds a linked list of hooks. For function components, React maintains a workInProgressHook pointer that advances through the list during each render:
// React's internal hook state (simplified)
let workInProgressHook: Hook | null = null;
let currentlyRenderingFiber: Fiber | null = null;
function mountState<S>(initialState: S): [S, Dispatch<BasicStateAction<S>>] {
// Create a new hook object
const hook: Hook = {
memoizedState: typeof initialState === 'function'
? initialState()
: initialState,
queue: { pending: null }, // Update queue
next: null, // Linked list pointer
};
// Append to the hook list on the fiber
if (workInProgressHook === null) {
currentlyRenderingFiber.memoizedState = hook;
workInProgressHook = hook;
} else {
workInProgressHook.next = hook;
workInProgressHook = hook;
}
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook.queue);
return [hook.memoizedState, dispatch];
}This is why hooks must be called in the same order every render — React relies on the linked-list order to match each useState call with its corresponding hook state. If you call hooks conditionally, the pointer advances incorrectly and you get the wrong state.
When you call setCount(prev => prev + 1), React creates an update object and adds it to the hook's queue:
function dispatchSetState<S>(fiber: Fiber, queue: UpdateQueue, action: BasicStateAction<S>) {
const update: Update = {
action, // The new value or updater function
next: null, // Circular linked list
lane: requestUpdateLane(fiber), // Priority lane
};
// Add to circular queue
const pending = queue.pending;
if (pending === null) {
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
// Schedule the fiber for re-rendering
scheduleUpdateOnFiber(fiber, update.lane);
}During the next render, React processes the queue and calculates the new state:
function updateState<S>(): [S, Dispatch<S>] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
let newState = hook.memoizedState;
// Process all pending updates in order
let update = queue.pending?.next; // Start of circular list
if (update !== null) {
do {
const action = update.action;
newState = typeof action === 'function'
? action(newState) // Functional update: setCount(prev => prev + 1)
: action; // Direct value: setCount(5)
update = update.next;
} while (update !== queue.pending.next);
}
hook.memoizedState = newState;
return [newState, dispatch];
}The queue processes updates in the order they were dispatched. Functional updates (prev => prev + 1) are critical in concurrent mode because they read from the accumulated state rather than a stale closure variable.
How useEffect Works in Fiber
Fiber nodes track effects using a circular linked list on the updateQueue:
function mountEffect(create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= PassiveEffect;
hook.memoizedState = pushEffect(
HookHasEffect | PassiveEffect, // Effect tag
create, // Your callback
undefined, // Destroy function (set after first run)
nextDeps // Dependency array
);
}
function pushEffect(tag, create, destroy, deps) {
const effect = { tag, create, destroy, deps, next: null };
const fiber = currentlyRenderingFiber;
let queue = fiber.updateQueue;
if (queue === null) {
queue = { lastEffect: null };
fiber.updateQueue = queue;
effect.next = effect;
queue.lastEffect = effect;
} else {
const lastEffect = queue.lastEffect;
if (lastEffect === null) {
effect.next = effect;
queue.lastEffect = effect;
} else {
effect.next = lastEffect.next;
lastEffect.next = effect;
queue.lastEffect = effect;
}
}
return effect;
}During the commit phase, React schedules passive effects to run after the browser paints:
function commitPassiveMountEffects(root, finishedWork) {
recursivelyTraversePassiveMountEffects(root, finishedWork);
commitPassiveMountOnFiber(root, finishedWork);
}
function commitPassiveMountOnFiber(finishedRoot, fiber) {
switch (fiber.tag) {
case FunctionComponent: {
const updateQueue = fiber.updateQueue;
const lastEffect = updateQueue?.lastEffect;
if (lastEffect) {
let effect = lastEffect.next;
do {
if (effect.tag & HookHasEffect) {
// Destroy previous effect
const destroy = effect.destroy;
if (destroy !== undefined) {
destroy();
}
// Create new effect
effect.destroy = effect.create();
}
effect = effect.next;
} while (effect !== lastEffect);
}
break;
}
}
}The key distinction: useLayoutEffect runs synchronously during the commit phase (after DOM mutations but before the browser paints), while useEffect is scheduled asynchronously and runs after the browser paints. This means useLayoutEffect blocks painting — use it only when you need to measure DOM layout.
Automatic Batching
React 18 automatically batches all state updates, not just those inside event handlers:
function handleClick() {
// React 17: TWO re-renders (one for each setState)
// React 18: ONE re-render (batched together)
setA(1);
setB(2);
}
// React 18 also batches updates in async contexts:
async function fetchData() {
const data = await fetch('/api');
// Both updates are batched into one re-render in React 18
setLoading(false);
setData(data);
}Fiber makes this possible because updates are queued as lanes. When multiple updates arrive in the same microtask, React assigns them the same lane and processes them together in a single render pass. In React 17, only updates inside React event handlers were batched — updates in setTimeout, Promise.then, and native event handlers caused separate re-renders.
Concurrent Features Enabled by Fiber
Suspense
Suspense works by allowing a component to "suspend" — telling React to skip it and come back later:
function SuspenseBoundary({ children, fallback }) {
try {
return children;
} catch (thrownValue) {
if (typeof thrownValue?.then === 'function') {
// Component suspended — show fallback
thrownValue.then(() => {
// Data ready — schedule re-render of this boundary
scheduleUpdate(boundaryFiber);
});
return fallback;
}
throw thrownValue; // Re-throw non-promise errors
}
}In Fiber terms, when a child component throws a promise (suspends), React walks up the fiber tree via the return pointers to find the nearest Suspense boundary. It marks that boundary's Fiber as having pending work and renders the fallback UI. When the promise resolves, React re-renders just that boundary's children, not the entire tree.
useTransition
The useTransition hook returns a startTransition function that marks updates as non-urgent:
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
// Urgent: update input immediately (SyncLane)
setQuery(e.target.value);
// Transition: can be deferred (TransitionLane)
startTransition(() => {
setResults(filterResults(e.target.value));
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultList results={results} />}
</div>
);
}When a transition is in progress and a new urgent update arrives, React can abandon the transition work-in-progress tree, process the urgent update first, and then restart the transition. This keeps the input responsive even when filtering a large dataset takes significant time.
useDeferredValue
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<ExpensiveList query={deferredQuery} />
</div>
);
}useDeferredValue is similar to useTransition but works when you don't control the state update — for example, when the value comes from a parent component's prop. It creates a deferred copy of a value that React can update at lower priority.
Offscreen Rendering
React 19 introduces offscreen rendering, which uses Fiber to hide and show components without unmounting:
// The Activity component keeps the tree mounted but hides it visually
function TabPanel({ children, activeTab }) {
return (
<>
{tabs.map(tab => (
<Activity mode={tab === activeTab ? 'visible' : 'hidden'} key={tab}>
<TabContent tab={tab} />
</Activity>
))}
</>
);
}When a tab is hidden, React doesn't unmount its Fiber tree — it marks it as offscreen. The component's state is preserved, DOM nodes are hidden via display: none, and effects are cleaned up. Switching tabs is instant because there's no reconciliation cost — React simply toggles the visibility flag on the Fiber and runs the appropriate effects.
Practical Implications
Why Keys Matter
Fiber uses keys to match old and new children during reconciliation:
// Without keys — React matches by index, causing unnecessary updates
<ul>
{items.map((item, i) => <li key={i}>{item.name}</li>)}
</ul>
// With stable keys — React correctly identifies moved/inserted/deleted items
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>When you use index keys and the list reorders, React thinks every item changed and re-renders all of them. With stable keys, React can surgically update only the moved items. This matters especially for lists with stateful components — using index keys causes components to lose their state when items are reordered.
Why Functional Updates Matter
// BAD: Stale closure in concurrent mode
setCount(count + 1); // Uses count from render closure — may be stale
// GOOD: Functional update always uses latest value
setCount(prev => prev + 1); // Reads from the queue during processingWith concurrent rendering, multiple renders can happen before an update is committed. The count variable in the closure might reference an outdated value. Functional updates read from the hook's pending queue, ensuring they always operate on the latest state.
React DevTools and Fiber
The React DevTools Profiler hooks into Fiber's commit phase to record timing:
// What DevTools records during profiling:
{
componentName: "UserProfile",
duration: 4.2, // Time in ms for this component's render phase
phases: {
"render": 3.1, // beginWork + completeWork
"commit": 1.1, // commitMutationEffects + commitLayoutEffects
},
actualDuration: 4.2, // Time to render this fiber (including memoized children)
selfBaseDuration: 1.1 // Time to render this fiber alone (excluding children)
}The flamegraph view shows the component tree as Fiber sees it — each bar represents a Fiber node, and the width represents how long that node took to render. A wide bar at the top with narrow children indicates the component itself is expensive, not its children. The ranked chart sorts components by actualDuration, helping you identify optimization targets.
The React Compiler
The React Compiler (formerly React Forget), released alongside React 19, analyzes component code at build time and inserts memoization automatically. It understands Fiber's reconciliation rules and determines when a component's output is guaranteed to be the same for the same inputs:
// You write this:
function TodoList({ todos }) {
const activeTodos = todos.filter(t => !t.completed);
return <ul>{activeTodos.map(t => <Todo key={t.id} todo={t} />)}</ul>;
}
// The compiler generates equivalent to:
function TodoList({ todos }) {
const activeTodos = useMemo(
() => todos.filter(t => !t.completed),
[todos]
);
return useMemo(
() => <ul>{activeTodos.map(t => <Todo key={t.id} todo={t} />)}</ul>,
[activeTodos]
);
}This eliminates the need for manual React.memo, useMemo, and useCallback calls. The compiler understands that todos.filter() returns a new array reference every render, so it wraps it in useMemo. It also understands that JSX returns a new element reference, so it memoizes the entire return value.
Best Practices
- Use stable keys — Help Fiber efficiently match and reuse nodes during reconciliation
- Use functional updates — Avoid stale closures in concurrent mode with
setCount(prev => prev + 1) - Use
startTransition— Mark non-urgent updates for deferred processing via TransitionLane - Keep components pure — Side effects in render phase cause bugs in StrictMode and concurrent rendering
- Prefer
useEffectoveruseLayoutEffect— Layout effects block painting and should only be used for DOM measurements - Use
React.memowisely — Prevent unnecessary re-renders of expensive components with stable props
Common Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Index as key | Poor list performance, lost state | Use stable unique IDs |
| Side effects in render | Unpredictable behavior, double-invocation | Use useEffect |
| Stale closures | Wrong values in concurrent mode | Use functional updates |
| Large component trees | Slow reconciliation | Split into smaller components |
| Mutating state directly | Skips re-render | Use setState or state setter |
| Layout effect for async work | Blocks painting | Use useEffect instead |
Conclusion
React Fiber is a complete rethinking of how React renders. By breaking work into small units stored in a linked-list tree, React can prioritize updates, pause rendering, and keep the UI responsive even under heavy load. The double buffering pattern, lane-based scheduling, and three-phase commit architecture enable features like Suspense, useTransition, and automatic batching.
Understanding Fiber helps you write better React code: stable keys for efficient reconciliation, functional updates for correctness in concurrent mode, and startTransition for responsive user interfaces. It's the foundation that makes React 18's and React 19's concurrent features possible.
Key takeaways:
- Fiber breaks rendering into interruptible units — Small units of work that can be paused and resumed via the linked-list tree
- Two phases: render (interruptible) and commit (synchronous) — Pure rendering, then atomic DOM mutations
- Double buffering — Two trees (current and work-in-progress) swapped atomically
- Lanes model — Bitmask-based priority scheduling for different update types
- Hooks are linked lists on the Fiber — useState and useEffect track state via a hook list on memoizedState
- Automatic batching — Multiple updates in the same microtask are batched into one render
- Suspense and transitions — Built on Fiber's ability to pause, abandon, and resume work
- React Compiler automates memoization by analyzing Fiber's reconciliation rules at build time