Chapter 25

Reconciler and Diff Algorithm Source Code Walkthrough

React's Reconciler is the brain of the Fiber architecture. It receives a new React element tree, compares it with the existing Fiber tree, determines which nodes need to be created, updated, or deleted, and marks corresponding Fiber nodes with effect flags. Understanding how the reconciler works means understanding how React propagates virtual DOM changes to the real DOM at minimum cost.

The Reconciler Entry Point: reconcileChildFibers

Each time beginWork processes a Fiber node and needs to handle that node's children, it calls either reconcileChildFibers (update scenario) or mountChildFibers (initial mount scenario). These two functions are actually two instances produced by the same factory function:

// packages/react-reconciler/src/ReactFiberReconciler.js
// Both functions come from the same factory; the only difference is shouldTrackSideEffects
export const reconcileChildFibers = createChildReconciler(true);
export const mountChildFibers = createChildReconciler(false);

The shouldTrackSideEffects parameter is crucial. When it is true (update scenario), the reconciler marks Fiber nodes with side-effect flags like Placement (needs insertion) and Deletion (needs removal). When it is false (initial mount), these flags are not marked โ€” because during the initial mount the entire tree is new, and React will insert the root node into the DOM atomically at final commit without needing to mark each node individually.

// packages/react-reconciler/src/ReactChildFiber.js (simplified)
function createChildReconciler(shouldTrackSideEffects) {
  
  function placeSingleChild(newFiber) {
    // Only mark Placement if tracking side effects and the node is newly created (no alternate)
    if (shouldTrackSideEffects && newFiber.alternate === null) {
      newFiber.flags |= Placement;
    }
    return newFiber;
  }
  
  function deleteChild(returnFiber, childToDelete) {
    if (!shouldTrackSideEffects) return;
    
    // Add the node to be deleted to the parent's deletions list
    const deletions = returnFiber.deletions;
    if (deletions === null) {
      returnFiber.deletions = [childToDelete];
      returnFiber.flags |= ChildDeletion;
    } else {
      deletions.push(childToDelete);
    }
  }
  
  // ... other helper functions
  
  function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
    // Core dispatch logic
    // Routes to different handlers based on the type of newChild
  }
  
  return reconcileChildFibers;
}

The Three Core Diff Rules

React's Diff algorithm is built on three rules. These rules did not arise from thin air โ€” they are engineering decisions made by the React team based on real-world Web application usage patterns.

Rule One: Different Type Means Destroy and Recreate

If the types of the new and old nodes differ, React makes no attempt to reuse anything. It deletes the old node and its entire subtree, then creates a new node from scratch.

// Type comparison in beginWork (simplified)
function updateElement(returnFiber, current, element, lanes) {
  const elementType = element.type;
  
  if (current !== null) {
    if (current.elementType === elementType) {
      // Same type โ€” reuse
      const existing = useFiber(current, element.props);
      existing.ref = coerceRef(returnFiber, current, element);
      existing.return = returnFiber;
      return existing;
    }
    // Different type: current is added to deletions, new Fiber is created
  }
  
  // Create a brand new node
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

Why not attempt "smart" reuse when node types differ? Because when the type changes (e.g., from <div> to <section>, or from ComponentA to ComponentB), the internal state is completely invalidated. Forcing reuse would only cause state contamination and bugs that are nightmares to track down โ€” it is cleaner to start fresh.

Rule Two: Same Type DOM Node โ€” Update Attributes Only

When the new and old nodes at the same position share the same type (e.g., both are <input type="text">), React reuses the existing DOM node and only updates the attributes that changed.

// packages/react-reconciler/src/ReactFiberCompleteWork.js (simplified)
function updateHostComponent(current, workInProgress, type, newProps) {
  const oldProps = current.memoizedProps;
  
  if (oldProps === newProps) {
    // Same props reference (memo scenario) โ€” no update needed
    return;
  }
  
  // Compare new and old props, generate list of attributes to update
  const updatePayload = prepareUpdate(
    current.stateNode,  // The real DOM node
    type,
    oldProps,
    newProps,
  );
  
  // Save the update payload on the work-in-progress node
  // The commit phase will use this payload to update the real DOM
  workInProgress.updateQueue = updatePayload;
  
  if (updatePayload) {
    markUpdate(workInProgress);
  }
}

prepareUpdate iterates over new and old props to find differences. For event listeners, it identifies them by the on prefix and replaces handler functions during updates. For style, it deeply compares every property of the style object.

Rule Three: Same Type Component โ€” Update Props and Continue Rendering

For components of the same type (function or class components), React reuses the existing Fiber node, passes the new props into the component, and re-executes rendering.

// packages/react-reconciler/src/ReactFiberBeginWork.js (simplified)
function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
  // Call the function component itself with new props
  let nextChildren = renderWithHooks(
    current,
    workInProgress,
    Component,
    nextProps,
    context,
    renderLanes,
  );
  
  // Reconcile children
  reconcileChildren(current, workInProgress, nextChildren, renderLanes);
  return workInProgress.child;
}

Single Child Reconciliation

When there is only one new child element, the reconciliation logic is relatively straightforward:

// packages/react-reconciler/src/ReactChildFiber.js (simplified)
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
  const key = element.key;
  let child = currentFirstChild;
  
  // Iterate existing child node linked list, looking for a reusable node
  while (child !== null) {
    if (child.key === key) {
      const elementType = element.type;
      
      if (child.tag === Fragment ? element.type === REACT_FRAGMENT_TYPE
                                 : child.elementType === elementType) {
        // Both key and type match โ€” reuse this node
        // Delete all other sibling nodes
        deleteRemainingChildren(returnFiber, child.sibling);
        
        const existing = useFiber(child, element.props);
        existing.return = returnFiber;
        return existing;
      }
      
      // Key matches but type does not โ€” delete this and all subsequent nodes
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // Key does not match โ€” delete this node and keep looking
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }
  
  // No reusable node found โ€” create a new one
  const created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.return = returnFiber;
  return created;
}

A detail worth noting: when a node with a matching key but mismatched type is found, React immediately calls deleteRemainingChildren (deleting the current node and all subsequent siblings), then breaks out of the loop. This is because per Diff Rule One, different types must be rebuilt; and since keys are unique among siblings, we already know no later node will match either, so there is no need to continue searching.

Multiple Children Reconciliation: The Two-Pass Algorithm

List diffing is the most complex part of the reconciliation algorithm. When the new children are an array, React uses a two-pass traversal strategy:

// packages/react-reconciler/src/ReactChildFiber.js (core logic simplified)
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  let resultingFirstChild = null;  // First node of the new linked list
  let previousNewFiber = null;     // Last processed new node
  
  let oldFiber = currentFirstChild;  // Current old node pointer
  let lastPlacedIndex = 0;           // Last confirmed-in-place index in the DOM
  let newIdx = 0;                    // Current index in the new array
  let nextOldFiber = null;
  
  // ===== Pass 1: Sequential comparison, stop when a key mismatch is found =====
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    if (oldFiber.index > newIdx) {
      nextOldFiber = oldFiber;
      oldFiber = null;
    } else {
      nextOldFiber = oldFiber.sibling;
    }
    
    // Attempt to reuse the old node
    const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
    
    if (newFiber === null) {
      // Key mismatch โ€” stop Pass 1
      if (oldFiber === null) oldFiber = nextOldFiber;
      break;
    }
    
    if (shouldTrackSideEffects) {
      if (oldFiber && newFiber.alternate === null) {
        // A different old node was used โ€” delete the original old node
        deleteChild(returnFiber, oldFiber);
      }
    }
    
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    // ... build new linked list
    oldFiber = nextOldFiber;
  }
  
  // Handle remaining cases after Pass 1 ends
  if (newIdx === newChildren.length) {
    // New elements exhausted โ€” delete remaining old nodes
    deleteRemainingChildren(returnFiber, oldFiber);
    return resultingFirstChild;
  }
  
  if (oldFiber === null) {
    // Old nodes exhausted โ€” create all remaining new elements
    for (; newIdx < newChildren.length; newIdx++) {
      const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
      // ... add to new linked list
    }
    return resultingFirstChild;
  }
  
  // ===== Pass 2: Handle out-of-order nodes =====
  // Put remaining old nodes into a Map, keyed by key (or index)
  const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
  
  for (; newIdx < newChildren.length; newIdx++) {
    const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
    
    if (newFiber !== null) {
      if (shouldTrackSideEffects) {
        if (newFiber.alternate !== null) {
          // Reused an old node from the Map โ€” remove from Map to prevent erroneous deletion
          existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
        }
      }
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
    }
  }
  
  if (shouldTrackSideEffects) {
    // Remaining old nodes in the Map have no corresponding new nodes โ€” delete them all
    existingChildren.forEach(child => deleteChild(returnFiber, child));
  }
  
  return resultingFirstChild;
}

placeChild: The Minimum-Move Algorithm

The placeChild function in Pass 2 determines whether a node needs to move:

function placeChild(newFiber, lastPlacedIndex, newIndex) {
  newFiber.index = newIndex;
  
  if (!shouldTrackSideEffects) return lastPlacedIndex;
  
  const current = newFiber.alternate;
  if (current !== null) {
    // This is a reused node โ€” check its position in the old list
    const oldIndex = current.index;
    
    if (oldIndex < lastPlacedIndex) {
      // Old position is to the left of already-stable nodes โ€” needs to move right
      newFiber.flags |= Placement;
      return lastPlacedIndex;
    } else {
      // Old position is to the right of already-stable nodes โ€” no move needed
      return oldIndex;
    }
  } else {
    // Newly created node โ€” needs insertion
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

This algorithm has a well-known performance trap: moving the last node to the first position. For example, [A, B, C, D] becoming [D, A, B, C] causes the algorithm to move A, B, and C three times rather than moving D once. This is because D's old index (3) is greater than all other nodes, so the algorithm concludes that A, B, and C all need to move rightward. This is precisely why keys should not use array indices โ€” when list order changes, using indices as keys causes a flood of unnecessary DOM operations.

React 19 Reconciliation Optimizations

React 19 introduced several optimizations at the reconciliation algorithm level. One of them is early bailout based on subtreeFlags: if a subtree's subtreeFlags and childLanes are both zero, it means the subtree has no changes at all and reconciliation of that subtree can be skipped entirely.

// React 19 early exit optimization in beginWork
function beginWork(current, workInProgress, renderLanes) {
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
    
    if (oldProps !== newProps || hasLegacyContextChanged()) {
      didReceiveUpdate = true;
    } else {
      // Props unchanged โ€” check for pending work
      const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(current, renderLanes);
      if (!hasScheduledUpdateOrContext) {
        didReceiveUpdate = false;
        // Reuse directly โ€” skip reconciliation
        return attemptEarlyBailoutIfNoScheduledUpdate(current, workInProgress, renderLanes);
      }
    }
  }
  // ...
}

The reconciler is the core battlefield of React performance optimization. Understanding how it works is not merely satisfying technical curiosity โ€” it is the foundation for writing high-performance React code. Using key correctly, avoiding unnecessary type changes, organizing component hierarchies thoughtfully โ€” all of these are rooted in a deep understanding of the reconciliation algorithm.

Rate this chapter
4.7  / 5  (5 ratings)

๐Ÿ’ฌ Comments