MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React Fiber Architecture: How React Works Internally

Deep dive into React Fiber: the reconciliation engine, work loop, priority scheduling, and how React renders components efficiently.

ReactFiberArchitecturePerformance

By MinhVo

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.

React code on screen

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 parent

This 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 rendering

React'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.

Software architecture diagram

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:

  1. Mutation phase — DOM insertions, updates, and deletions happen here
  2. Layout phase — useLayoutEffect callbacks run synchronously after DOM mutations but before the browser paints
  3. Passive phase — useEffect callbacks are scheduled asynchronously via MessageChannel, 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.

Performance visualization

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 processing

With 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

  1. Use stable keys — Help Fiber efficiently match and reuse nodes during reconciliation
  2. Use functional updates — Avoid stale closures in concurrent mode with setCount(prev => prev + 1)
  3. Use startTransition — Mark non-urgent updates for deferred processing via TransitionLane
  4. Keep components pure — Side effects in render phase cause bugs in StrictMode and concurrent rendering
  5. Prefer useEffect over useLayoutEffect — Layout effects block painting and should only be used for DOM measurements
  6. Use React.memo wisely — Prevent unnecessary re-renders of expensive components with stable props

Common Pitfalls

PitfallImpactSolution
Index as keyPoor list performance, lost stateUse stable unique IDs
Side effects in renderUnpredictable behavior, double-invocationUse useEffect
Stale closuresWrong values in concurrent modeUse functional updates
Large component treesSlow reconciliationSplit into smaller components
Mutating state directlySkips re-renderUse setState or state setter
Layout effect for async workBlocks paintingUse 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:

  1. Fiber breaks rendering into interruptible units — Small units of work that can be paused and resumed via the linked-list tree
  2. Two phases: render (interruptible) and commit (synchronous) — Pure rendering, then atomic DOM mutations
  3. Double buffering — Two trees (current and work-in-progress) swapped atomically
  4. Lanes model — Bitmask-based priority scheduling for different update types
  5. Hooks are linked lists on the Fiber — useState and useEffect track state via a hook list on memoizedState
  6. Automatic batching — Multiple updates in the same microtask are batched into one render
  7. Suspense and transitions — Built on Fiber's ability to pause, abandon, and resume work
  8. React Compiler automates memoization by analyzing Fiber's reconciliation rules at build time