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
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 → document.createElement), appends the already-built DOM children into it (appendAllChildren), and sets its attributes (finalizeInitialChildren → setInitialProperties). The result is a complete DOM subtree that is not attached to the document. On updates, React 19 does not diff props here: it only checks oldProps !== newProps and sets the Update flag (markUpdate). The actual attribute diff happens in the commit phase (commitUpdate → updateProperties). Every fiber finishes with bubbleProperties, which ORs children's flags/lanes into subtreeFlags/childLanes.
Where it's calledCopy link to section:
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:
case LazyComponent: case SimpleMemoComponent: case FunctionComponent:
case ForwardRef: case Fragment: case Mode: case Profiler:
case ContextConsumer: case MemoComponent:
bubbleProperties(workInProgress);
return null;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 mountCopy link to section:
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;
}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. createInstanceCopy link to section:
// 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;
}- Host context is a stack pushed in
beginWork(pushHostContext) and popped here. It's how a<path>inside<svg>knows to usecreateElementNS. 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 viainnerHTMLon a throwawaydiv. Scripts created that way are inert, matching SSR behaviour.precacheFiberNodewrites the fiber onto the node under a random key ('__reactFiber$' + randomKey).updateFiberPropswrites the props under'__reactProps$' + randomKey.678 The event system relies on both: givenevent.target, it finds the fiber via__reactFiber$, and reads the latestonClickfrom__reactProps$(Event System). This is also whycommitUpdatecallsupdateFiberPropsagain on every update. Changing only a handler function results in a cheap property write and noaddEventListenerchurn, because React delegates events at the root.
2. appendAllChildren: the off-screen treeCopy link to section:
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;
}
}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:
Diagram source:
sequenceDiagram
participant h1 as h1 complete
participant li as li a / li b complete
participant ul as ul complete
participant div as div complete
participant Root as commit (later)
h1->>h1: createElement('h1'), textContent='Hi'
li->>li: createElement('li') ×2
ul->>ul: createElement('ul'), append li, li
div->>div: createElement('div'), append h1, (skip List fiber) ul
Note over div: whole subtree exists, detached from document
Root->>Root: container.appendChild(div) — ONE live insertionWhy 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
appendChildinto the container does, in the commit phase, and it does so once. That's why only App carriesPlacementon 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
completeWorksuffices. The sourceTODOeven mentions movingcreateInstancetobeginWorkand appending top-down as a possible alternative ("Top->down is faster in IE11").4
A portal's children belong in a different container, and HostSingleton (<html>, <head>, <body>) reuses existing document nodes. Both get placed independently in the commit phase, so appendAllChildren skips descending into them.
3. finalizeInitialChildren → setInitialPropertiesCopy link to section:
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;
}
}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.
HostTextCopy link to section:
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;
}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
HostRootCopy link to section:
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 diffCopy link to section:
// 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)
}
}That's the entire update logic for a DOM element in the render phase. It's an identity check followed by a flag.
In React ≤18, this function called the host config's prepareUpdate(), which ran diffProperties and stored an update payload (a flat [key, value, key, value] array) in workInProgress.updateQueue. The commit phase then applied that payload. PR #26583, "Diff properties in the commit phase instead of generating an update payload", removed it.21 In 19.3.1 there is no prepareUpdate anywhere in the reconciler or DOM host config. The diff and the DOM writes now happen in a single pass in the commit phase. Per the PR, that avoids allocating temporary payload objects during render and reduces total work. The tradeoff is that an element whose props object changed but whose values didn't still costs a full prop loop at commit time.
At commit, commitMutationEffectsOnFiber sees flags & Update on a HostComponent and calls commitHostUpdate(finishedWork, newProps, oldProps) → the DOM config's commitUpdate:2223
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);
}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
A parent re-render recreates <div className="row"> with a new props object, so the <div> fiber gets Update and the commit runs updateProperties. The loop finds every value ===, so no DOM writes happen, but you still pay for the comparison. And style={{...}} literals are new objects, so style is always "changed" at the prop level (setProp → setValueForStyles diffs individual style keys). React DevTools' "highlight updates" shows renders, not DOM writes, and neither is the same as "the browser repainted".
bubbleProperties: subtreeFlags and childLanesCopy link to section:
Every completeWork path ends in bubbleProperties(completedWork):
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;
}Two aggregates are computed on the way up:
subtreeFlags: the union of all effect flags anywhere below this fiber. The commit phase checksparentFiber.subtreeFlags & MutationMask(orLayoutMask,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), andcommitRootreadsfinishedWork.lanes | finishedWork.childLanesas 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, summarizedCopy link to section:
With the React CompilerCopy link to section:
completeWork is unchanged. The compiler only rewrites component code. But it changes the inputs here in two ways:
- Subtrees that bail out in
beginWorkbecause the compiler returned a cached element never reachupdateHostComponentwith a fresh props object. Their host fibers take thedidBailoutpath inbubbleProperties, so noUpdateflag and noupdatePropertiesloop at commit. - 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, sooldProps === newPropson that host fiber, which skipsmarkUpdate. 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.
SummaryCopy link to section:
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 takeawaysCopy link to section:
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: callssetInitialPropertiesafter children are appended, and returnstrueforautoFocusorimgto request a commit-timecommitMount.HostRootandSnapshot: on the first client render, the root is flagged soclearContainerwipes 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 removedprepareUpdate.commitUpdate→updateProperties: diffs and writes props in one commit-time pass, soUpdatedoes not mean the DOM changed.bubbleProperties: replaces the old effect list with per-fibersubtreeFlags, and bubbles onlyStaticMaskflags when the children were bailed out.
← beginWork & Reconciliation · Index · Commit Phase →
Footnotes
-
completeUnitOfWork— packages/react-reconciler/src/ReactFiberWorkLoop.js#L3368-L3431 (opens in a new tab) ↩ -
completeWork— packages/react-reconciler/src/ReactFiberCompleteWork.js#L1082-L2112 (opens in a new tab) ↩ -
completeWorktrivial cases — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1100-L1110 (opens in a new tab) ↩ -
completeWorkHostComponent case — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1374-L1473 (opens in a new tab) ↩ ↩2 -
createInstance— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L534-L664 (opens in a new tab) ↩ -
internal keys — packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L44-L45 (opens in a new tab) ↩
-
precacheFiberNode— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L94-L111 (opens in a new tab) ↩ -
updateFiberProps— packages/react-dom-bindings/src/client/ReactDOMComponentTree.js#L295-L301 (opens in a new tab) ↩ -
appendAllChildren— packages/react-reconciler/src/ReactFiberCompleteWork.js#L245-L352 (opens in a new tab) ↩ -
appendInitialChild— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L695-L701 (opens in a new tab) ↩ -
finalizeInitialChildren— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L703-L721 (opens in a new tab) ↩ -
setInitialProperties(img ordering at L1134-L1140) — packages/react-dom-bindings/src/client/ReactDOMComponent.js#L1111-L1496 (opens in a new tab) ↩ -
commitMount— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L891-L954 (opens in a new tab) ↩ -
completeWorkHostText case — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1474-L1508 (opens in a new tab) ↩ -
createTextInstance— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L757-L779 (opens in a new tab) ↩ -
updateHostText(complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L674-L703 (opens in a new tab) ↩ -
commitTextUpdate— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L1018-L1024 (opens in a new tab) ↩ -
completeWorkHostRoot case (Snapshotat L1178) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L1119-L1199 (opens in a new tab) ↩ -
updateHostComponent(complete phase) — packages/react-reconciler/src/ReactFiberCompleteWork.js#L463-L550 (opens in a new tab) ↩ -
markUpdate— packages/react-reconciler/src/ReactFiberCompleteWork.js#L201-L203 (opens in a new tab) ↩ -
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) ↩
-
mutation phase HostComponent
Updatehandling — packages/react-reconciler/src/ReactFiberCommitWork.js#L2302-L2314 (opens in a new tab) ↩ -
commitHostUpdate— packages/react-reconciler/src/ReactFiberCommitHostEffects.js#L114-L143 (opens in a new tab) ↩ -
commitUpdate— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L999-L1012 (opens in a new tab) ↩ -
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) ↩ -
bubbleProperties— packages/react-reconciler/src/ReactFiberCompleteWork.js#L793-L914 (opens in a new tab) ↩ -
recursivelyTraverseMutationEffectssubtreeFlags check — packages/react-reconciler/src/ReactFiberCommitWork.js#L2046-L2068 (opens in a new tab) ↩ -
commitRootremaining lanes — packages/react-reconciler/src/ReactFiberWorkLoop.js#L3736-L3766 (opens in a new tab) ↩