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
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:
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 mount
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. createInstance
// 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 tree
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:
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
3. finalizeInitialChildren → setInitialProperties
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.
HostText
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
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
// 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.
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
bubbleProperties: subtreeFlags and childLanes
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, summarized
With the React Compiler
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.
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: 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) ↩