completeWork - Building DOM Nodes

completeWork runs on the way up the tree, once all of a fiber's children are done. For a newly mounted host fiber it creates the real DOM node (createInstance →…

Published
Reading time
10 min read
React InternalsPart 08 · 9 of 18

Where it's called

completeUnitOfWork calls completeWork(current, workInProgress, renderLanes) for a fiber when beginWork returned no child, then continues with the sibling or climbs to the parent (Work Loop).1 So the order is post-order: children complete before their parent. That ordering is the reason the DOM can be built bottom-up.

completeWork is a big switch on tag.2 For most non-host fibers (function components, fragments, memo, context consumers…) it does nothing but bubbleProperties:

JavaScript
case LazyComponent: case SimpleMemoComponent: case FunctionComponent:
case ForwardRef: case Fragment: case Mode: case Profiler:
case ContextConsumer: case MemoComponent:
  bubbleProperties(workInProgress);
  return null;

3

The interesting work is for HostComponent, HostText and HostRoot. Suspense, Offscreen/Activity and SuspenseList also have big cases (visibility toggling, retry wiring), but they're outside the first-render/setState path.

HostComponent on mount

JavaScript
case HostComponent: {
  popHostContext(workInProgress);
  const type = workInProgress.type;
  if (current !== null && workInProgress.stateNode != null) {
    updateHostComponent(current, workInProgress, type, newProps, renderLanes);  // update
  } else {
    const currentHostContext = getHostContext();
    const wasHydrated = popHydrationState(workInProgress);
    if (wasHydrated) {
      prepareToHydrateHostInstance(workInProgress, currentHostContext);
    } else {
      const rootContainerInstance = getRootHostContainer();
      const instance = createInstance(type, newProps, rootContainerInstance,
                                      currentHostContext, workInProgress);
      markCloned(workInProgress);
      appendAllChildren(instance, workInProgress, false, false);
      workInProgress.stateNode = instance;
      if (finalizeInitialChildren(instance, type, newProps, currentHostContext)) {
        markUpdate(workInProgress);
      }
    }
  }
  bubbleProperties(workInProgress);
  // ...
  preloadInstanceAndSuspendIfNeeded(workInProgress, /* ... */);  // suspensey images/styles
  return null;
}

4

There are three host-config calls, all implemented by react-dom-bindings' ReactFiberConfigDOM.js (Bootstrap covers how the reconciler is parameterised by a host config).

1. createInstance

JavaScript
// ReactFiberConfigDOM.js (simplified)
export function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
  const ownerDocument = getOwnerDocumentFromRootContainer(rootContainerInstance);
  let domElement;
  switch (hostContext) {
    case HostContextNamespaceSvg:  domElement = ownerDocument.createElementNS(SVG_NAMESPACE, type); break;
    case HostContextNamespaceMath: domElement = ownerDocument.createElementNS(MATH_NAMESPACE, type); break;
    default:
      switch (type) {
        case 'svg':    /* createElementNS */ break;
        case 'script': /* innerHTML trick so the script does NOT execute */ break;
        case 'select': /* set multiple/size before children are appended */ break;
        default:       domElement = ownerDocument.createElement(type); // or {is: props.is}
      }
  }
  precacheFiberNode(internalInstanceHandle, domElement);
  updateFiberProps(domElement, props);
  return domElement;
}

5

  • Host context is a stack pushed in beginWork (pushHostContext) and popped here. It's how a <path> inside <svg> knows to use createElementNS. The namespace is inherited from ancestors, which you can only know top-down. That's why the stack is pushed in the begin phase even though the element is created in the complete phase.
  • <script> is created via innerHTML on a throwaway div. Scripts created that way are inert, matching SSR behaviour.
  • precacheFiberNode writes the fiber onto the node under a random key ('__reactFiber$' + randomKey). updateFiberProps writes the props under '__reactProps$' + randomKey.678 The event system relies on both: given event.target, it finds the fiber via __reactFiber$, and reads the latest onClick from __reactProps$ (Event System). This is also why commitUpdate calls updateFiberProps again on every update. Changing only a handler function results in a cheap property write and no addEventListener churn, because React delegates events at the root.

2. appendAllChildren: the off-screen tree

JavaScript
function appendAllChildren(parent, workInProgress, needsVisibilityToggle, isHidden) {
  // supportsMutation branch (DOM)
  let node = workInProgress.child;
  while (node !== null) {
    if (node.tag === HostComponent || node.tag === HostText) {
      appendInitialChild(parent, node.stateNode);   // parent.appendChild(child)
    } else if (node.tag === HostPortal || node.tag === HostSingleton) {
      // don't descend: portals/singletons are placed independently
    } else if (node.child !== null) {
      node.child.return = node;
      node = node.child;                             // descend through components
      continue;
    }
    if (node === workInProgress) return;
    while (node.sibling === null) {
      if (node.return === null || node.return === workInProgress) return;
      node = node.return;
    }
    node.sibling.return = node.return;
    node = node.sibling;
  }
}

910

The walk descends through non-host fibers (your components, fragments) but stops at the first host fiber on each path. It only needs the nearest host descendants, because each of those already contains its own subtree: it ran appendAllChildren when it completed.

So, for the tree from page 06:

Why build bottom-up off-document?

  • One insertion into the live document. Appending to a detached node doesn't trigger style recalculation or layout. Only the final appendChild into the container does, in the commit phase, and it does so once. That's why only App carries Placement on the first render (beginWork & Reconciliation).
  • Interruptible and discardable. A concurrent render may be thrown away. Detached nodes that are never inserted are just garbage collected, and the user never sees a partial tree. If React inserted top-down during render, an interrupted render would leave half a UI on screen.
  • Simplicity. A parent's children are always finished when the parent completes, so a single pass in completeWork suffices. The source TODO even mentions moving createInstance to beginWork and appending top-down as a possible alternative ("Top->down is faster in IE11").4

3. finalizeInitialChildren → setInitialProperties

JavaScript
export function finalizeInitialChildren(domElement, type, props, hostContext) {
  setInitialProperties(domElement, type, props);
  switch (type) {
    case 'button': case 'input': case 'select': case 'textarea':
      return !!props.autoFocus;
    case 'img':
      return true;
    default:
      return false;
  }
}

11

setInitialProperties loops over props and calls setProp for each one: attributes, style, className, string children → textContent, dangerouslySetInnerHTML. It has tag-specific paths for input/select/textarea (controlled value wrappers) and img (it sets src/srcSet last so crossOrigin/sizes apply before the fetch starts, and it attaches non-delegated load/error listeners).12

Attributes are set before the node is in the document, and after its children were appended. For <select>, children (<option>s) must exist before value is applied, which is one reason for that ordering.

A true return means "I need a commit-time effect on mount": markUpdate → Update flag → in the layout phase, commitHostMount → commitMount, which calls .focus() for autoFocus or re-assigns img.src so onLoad fires reliably.13 Focus has to wait until the node is actually in the document.

HostText

JavaScript
case HostText: {
  const newText = newProps;
  if (current && workInProgress.stateNode != null) {
    updateHostText(current, workInProgress, current.memoizedProps, newText);
  } else {
    // (hydration branch omitted)
    markCloned(workInProgress);
    workInProgress.stateNode = createTextInstance(newText, rootContainerInstance,
                                                  currentHostContext, workInProgress);
  }
  bubbleProperties(workInProgress);
  return null;
}

14

createTextInstance → document.createTextNode(text) + precacheFiberNode.15 Text fibers only exist for text that isn't the sole child (<p>Hello {name}</p> produces two text fibers). A sole string child is set as textContent via the shouldSetTextContent path in beginWork. On update, updateHostText simply does if (oldText !== newText) markUpdate(wip), and the commit sets textNode.nodeValue = newText.1617

HostRoot

HostRoot completion pops the root contexts (host container, cache, transitions). On the first client render (current.child === null and not hydrating) it sets the Snapshot flag on the root. That schedules clearContainer(root.containerInfo) in the before-mutation phase, so anything already in <div id="root"> (a "Loading…" placeholder, say) is wiped before React inserts its tree.18 See Commit Phase.

On update: mark, don't diff

JavaScript
// ReactFiberCompleteWork.js — mutation-mode branch of updateHostComponent
function updateHostComponent(current, workInProgress, type, newProps, renderLanes) {
  if (supportsMutation) {
    const oldProps = current.memoizedProps;
    if (oldProps === newProps) {
      // In mutation mode, this is sufficient for a bailout because
      // we won't touch this node even if children changed.
      return;
    }
    markUpdate(workInProgress);       // workInProgress.flags |= Update
  } else if (supportsPersistence) {
    // clone instance (React Native Fabric path)
  }
}

1920

That's the entire update logic for a DOM element in the render phase. It's an identity check followed by a flag.

At commit, commitMutationEffectsOnFiber sees flags & Update on a HostComponent and calls commitHostUpdate(finishedWork, newProps, oldProps) → the DOM config's commitUpdate:2223

JavaScript
export function commitUpdate(domElement, type, oldProps, newProps, internalInstanceHandle) {
  // Diff and update the properties.
  updateProperties(domElement, type, oldProps, newProps);
  // Update the props handle so that we know which props are the ones with
  // with current event handlers.
  updateFiberProps(domElement, newProps);
}

24

updateProperties is the diff. For common tags (div, span, p, a, li, svg, …) it goes straight to two generic loops. The first loop unsets props present in lastProps but missing from nextProps. The second sets props where nextProp !== lastProp. input, select, textarea, option, img, custom elements and a few others have special paths (controlled values must be applied after other attributes).25

bubbleProperties: subtreeFlags and childLanes

Every completeWork path ends in bubbleProperties(completedWork):

JavaScript
function bubbleProperties(completedWork) {
  const didBailout =
    completedWork.alternate !== null &&
    completedWork.alternate.child === completedWork.child;
  let newChildLanes = NoLanes;
  let subtreeFlags = NoFlags;
  if (!didBailout) {
    let child = completedWork.child;
    while (child !== null) {
      newChildLanes = mergeLanes(newChildLanes, mergeLanes(child.lanes, child.childLanes));
      subtreeFlags |= child.subtreeFlags;
      subtreeFlags |= child.flags;
      child.return = completedWork;
      child = child.sibling;
    }
  } else {
    // Children are the current fibers (bailout): only bubble StaticMask flags.
    // ... same loop with `& StaticMask` ...
  }
  completedWork.subtreeFlags |= subtreeFlags;
  completedWork.childLanes = newChildLanes;
  return didBailout;
}

26

Two aggregates are computed on the way up:

  • subtreeFlags: the union of all effect flags anywhere below this fiber. The commit phase checks parentFiber.subtreeFlags & MutationMask (or LayoutMask, PassiveMask) before descending, so clean subtrees are skipped in O(1).27 This replaced React 17's linked "effect list". Instead of threading a list of effectful fibers through the tree during complete, React now stores one bitmask per fiber and walks the tree in the commit phase, pruning by mask. It's simpler, and it works with Suspense/Offscreen re-traversal.
  • childLanes: the union of pending lanes below. After a render, this is what's left over (skipped lower-priority updates), and commitRoot reads finishedWork.lanes | finishedWork.childLanes as the root's remaining work.28

The bailout branch matters. When completedWork.child is still the current child (the subtree was skipped in beginWork), those children's flags belong to a previous commit and must not be re-applied, so only StaticMask flags are bubbled. Static flags (e.g. "this subtree contains a passive effect", "has a ref") describe the fiber's lifetime rather than one render, and they're needed when Offscreen hides or reveals a tree.

First render vs update, summarized

First renderUpdate
HostComponentcreateInstance + appendAllChildren + setInitialProperties. Update only for autoFocus/imgoldProps !== newProps → Update
HostTextcreateTextNodeoldText !== newText → Update
HostRootSnapshot → clear containernothing special
Placementonly on HostRoot's child (from page 07)on inserted/moved fibers
DOM writes during renderon detached nodes onlynone (all deferred to commit)

With the React Compiler

completeWork is unchanged. The compiler only rewrites component code. But it changes the inputs here in two ways:

  1. Subtrees that bail out in beginWork because the compiler returned a cached element never reach updateHostComponent with a fresh props object. Their host fibers take the didBailout path in bubbleProperties, so no Update flag and no updateProperties loop at commit.
  2. Inside a component that does re-render, the compiler caches JSX at the granularity of its reactive scopes. A static <div className="row"> whose inputs never change is created once and reused, so oldProps === newProps on that host fiber, which skips markUpdate. Without the compiler, that same div is "updated" (with a no-op diff) on every parent render.

So the compiler cuts both render-phase work and commit-phase prop diffing. See With vs Without Compiler.

Summary

completeWork is the upward half of the render phase: it runs post-order, once all of a fiber's children are done, which is what lets React build the DOM bottom-up. A newly mounted host fiber creates its node, appends its nearest host descendants and sets its attributes, so the finished subtree sits detached from the document: one live appendChild at commit, or garbage if the render is discarded. On updates React 19 does not diff props here; it only checks oldProps !== newProps and sets Update, leaving the diff to the commit phase. Every fiber then bubbles its children's flags and lanes into subtreeFlags and childLanes, which is how the commit phase knows which subtrees to skip.

Key takeaways

  • createInstance: creates the node using the host context stack (for SVG namespaces) and stores fiber and props on it for the event system.
  • appendAllChildren: descends through component fibers to the nearest host descendants, skipping portals and singletons, to assemble the tree off-document.
  • finalizeInitialChildren: calls setInitialProperties after children are appended, and returns true for autoFocus or img to request a commit-time commitMount.
  • HostRoot and Snapshot: on the first client render, the root is flagged so clearContainer wipes any placeholder content before insertion.
  • markUpdate: the whole render-phase update logic for a DOM element is an identity check plus a flag, since PR #26583 removed prepareUpdate.
  • commitUpdate → updateProperties: diffs and writes props in one commit-time pass, so Update does not mean the DOM changed.
  • bubbleProperties: replaces the old effect list with per-fiber subtreeFlags, and bubbles only StaticMask flags when the children were bailed out.

← beginWork & Reconciliation · Index · Commit Phase →

Footnotes

  1. completeUnitOfWork — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3368-L3431 (opens in a new tab) ↩

  2. completeWork — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1082-L2112 (opens in a new tab) ↩

  3. completeWork trivial cases — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1100-L1110 (opens in a new tab) ↩

  4. completeWork HostComponent case — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1374-L1473 (opens in a new tab) ↩ ↩2

  5. createInstance — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L534-L664 (opens in a new tab) ↩

  6. internal keys — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L44-L45 (opens in a new tab) ↩

  7. precacheFiberNode — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L94-L111 (opens in a new tab) ↩

  8. updateFiberProps — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L295-L301 (opens in a new tab) ↩

  9. appendAllChildren — packages/react-reconciler/src/ReactFiberCompleteWork.js#L245-L352 (opens in a new tab) ↩

  10. appendInitialChild — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L695-L701 (opens in a new tab) ↩

  11. finalizeInitialChildren — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L703-L721 (opens in a new tab) ↩

  12. setInitialProperties (img ordering at L1134-L1140) — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L1111-L1496 (opens in a new tab) ↩

  13. commitMount — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L891-L954 (opens in a new tab) ↩

  14. completeWork HostText case — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1474-L1508 (opens in a new tab) ↩

  15. createTextInstance — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L757-L779 (opens in a new tab) ↩

  16. updateHostText (complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L674-L703 (opens in a new tab) ↩

  17. commitTextUpdate — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1018-L1024 (opens in a new tab) ↩

  18. completeWork HostRoot case (Snapshot at L1178) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1119-L1199 (opens in a new tab) ↩

  19. updateHostComponent (complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L463-L550 (opens in a new tab) ↩

  20. markUpdate — packages/react-reconciler/src/ReactFiberCompleteWork.js#L201-L203 (opens in a new tab) ↩

  21. facebook/react PR #26583 "Diff properties in the commit phase instead of generating an update payload" — https://github.com/facebook/react/pull/26583 (opens in a new tab) ↩

  22. mutation phase HostComponent Update handling — packages/react-reconciler/src/ReactFiberCommitWork.js#L2302-L2314 (opens in a new tab) ↩

  23. commitHostUpdate — packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L114-L143 (opens in a new tab) ↩

  24. commitUpdate — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩

  25. updateProperties (fast-track tags L1528-L1539, generic diff loops L2002-L2022) — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L1518-L2023 (opens in a new tab) ↩

  26. bubbleProperties — packages/react-reconciler/src/ReactFiberCompleteWork.js#L793-L914 (opens in a new tab) ↩

  27. recursivelyTraverseMutationEffects subtreeFlags check — packages/react-reconciler/src/ReactFiberCommitWork.js#L2046-L2068 (opens in a new tab) ↩

  28. commitRoot remaining lanes — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3736-L3766 (opens in a new tab) ↩