2.1 The walk: beginWork and completeWork
You have a tree of Fibers. To turn it into screen pixels, React has to visit every Fiber and do work on it. This lesson answers one question: in what order does React walk the tree, and what does it actually do at each stop along the way?
One Fiber, visited twice
Here is the key idea, and it surprises most people: React visits each Fiber twice, not once.
The first visit happens on the way down the tree. React calls a function named beginWork on the Fiber. This is where React figures out what the Fiber's children should be.
The second visit happens later, on the way back up. React calls a function named completeWork on the same Fiber. This is where React builds the actual DOM node for it.
Down means beginWork. Up means completeWork. That is the whole shape of the walk.
┌────────────────────────────────────────────────────────────┐
│ PHASE 1: beginWork() — Top-Down (Depth-First) │
├────────────────────────────────────────────────────────────┤
│ • Called when visiting a fiber for the first time │
│ • Calls the component function (hooks run here) │
│ • Creates / reconciles child fibers │
│ • Returns next fiber to process (usually first child) │
│ • If returns null → no children → Phase 2 begins │
└────────────────────────────────────────────────────────────┘
↓
Has children? YES
↓
Process children (beginWork)
↓
Reach leaf node (no children)
↓
┌────────────────────────────────────────────────────────────┐
│ PHASE 2: completeWork() — Bottom-Up │
├────────────────────────────────────────────────────────────┤
│ • Called after all children are processed │
│ • Creates or updates a DOM node in memory │
│ • Diffs props to determine what changed │
│ • Bubbles effect flags up to parent │
│ • Returns to parent's sibling or parent │
└────────────────────────────────────────────────────────────┘
The order you would actually walk it
The walk is depth-first. That just means: when a Fiber has a child, go into the child before looking at the next sibling. Go as deep as you can, hit a leaf (a Fiber with no children), then turn around.
Trace it on this small tree. Each ↓ is a step down (beginWork); each ↑ is a step back up (completeWork).
<App>
├─ <Header>
│ └─ <h1>Title</h1>
└─ <Content>
├─ <p>Text</p>
└─ <Footer>
1. beginWork(<App>) ↓ Going down
2. beginWork(<Header>) ↓
3. beginWork(<h1>) ↓
4. beginWork("Title") ↓ text has no children
5. completeWork("Title") ↑ Coming back up
6. completeWork(<h1>) ↑
7. completeWork(<Header>) ↑
8. beginWork(<Content>) ↓ Next sibling, go down again
9. beginWork(<p>) ↓
10. beginWork("Text") ↓
11. completeWork("Text") ↑
12. completeWork(<p>) ↑
13. beginWork(<Footer>) ↓ Next sibling
14. completeWork(<Footer>) ↑ (no children assumed)
15. completeWork(<Content>) ↑
16. completeWork(<App>) ↑ Done!
Notice the rhythm. <Header> is fully finished (step 7) before <Content> even begins (step 8). And a parent's completeWork always comes after every one of its descendants is done — completeWork(<App>) is dead last.
The driver: performUnitOfWork
So what actually moves the pointer through the tree? A single function: performUnitOfWork. React calls it once per Fiber. One Fiber is one "unit of work" — hence the name.
It does exactly two things. First it runs beginWork (the down step). Then it looks at what beginWork handed back:
- If
beginWorkreturns a child Fiber, React keeps going down — that child becomes the next unit of work. - If
beginWorkreturnsnull(this Fiber has no children), there is nowhere left to go down, so React turns around and starts the up-pass.
The real code
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
// ========== PHASE 1: beginWork (go down) ==========
let next = beginWork(current, unitOfWork, renderLanes);
// Returns next fiber to work on (usually first child)
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// ========== NO CHILD: Start PHASE 2 (come back up) ==========
completeUnitOfWork(unitOfWork); // <- This calls completeWork!
} else {
// ========== HAS CHILD: Continue PHASE 1 ==========
workInProgress = next; // Move to child
}
}
That workInProgress = next line is the whole "going down" step — it just moves React's global pointer to the child. A loop outside this function keeps calling performUnitOfWork on whatever workInProgress points to, so the walk continues on its own until the tree is done.
Going down: what beginWork does
beginWork has a clear job on each Fiber it visits: figure out this Fiber's children, and return the first one so the walk can descend into it.
For a function component, that means React actually calls your component function to get the JSX it returns, then turns that JSX into child Fibers. The step that compares the new children against the old ones is called reconciliation.
The real code — ReactFiberBeginWork.js ~900
function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
// ── CALL THE COMPONENT ──────────────────────────────────────
let nextChildren = renderWithHooks(
current, workInProgress, Component, nextProps, context, renderLanes,
);
// ↑ calls Component(props) — all hooks run inside here
if (current !== null && !didReceiveUpdate) {
// Hooks ran but nothing changed (e.g. useState returned same value)
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
// ── RECONCILE CHILDREN ──────────────────────────────────────
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child; // return first child to visit next
}
The last line is the one that drives the walk: return workInProgress.child. That returned child is the next value back in performUnitOfWork — the Fiber React descends into.
One more thing to know about going down: beginWork can also decide there is nothing to do. If a Fiber's props did not change and it has no pending work, React takes a shortcut called a bailout and skips re-rendering that subtree. You can see that shortcut above as the early return bailoutOnAlreadyFinishedWork(...). We will not unpack bailout fully here — for now, just know it is how beginWork avoids work it does not need to do.
Show the full beginWork entry point
The top of beginWork does the bailout check, then a switch sends each Fiber type to its own handler (function component, class component, host element like <div>, plain text, and so on).
function beginWork(
current, // the fiber currently on screen (null on first mount)
workInProgress, // the fiber we are building
renderLanes // the priority lanes for this render
) {
// ── BAILOUT CHECK ──────────────────────────────────────────
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps || // props reference changed
hasContextChanged() || // context changed
(workInProgress.lanes & renderLanes) !== NoLanes // has pending work
) {
didReceiveUpdate = true; // must process
} else {
// ✅ Nothing changed — skip!
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
} else {
didReceiveUpdate = false; // first mount — always process
}
// Clear lanes: we are processing them right now
workInProgress.lanes = NoLanes;
// ── DISPATCH BY FIBER TYPE ──────────────────────────────────
switch (workInProgress.tag) {
case FunctionComponent: {
const Component = workInProgress.type;
return updateFunctionComponent(
current, workInProgress, Component,
workInProgress.pendingProps, renderLanes,
);
}
case ClassComponent: {
return updateClassComponent(
current, workInProgress, workInProgress.type,
workInProgress.pendingProps, renderLanes,
);
}
case HostComponent: { // <div>, <span>, etc.
return updateHostComponent(current, workInProgress, renderLanes);
}
case HostText: { // text nodes
return null; // text has no children — triggers Phase 2
}
// ... other fiber types ...
}
}
Look at the HostText case: text has no children, so it returns null immediately. That null is exactly the signal performUnitOfWork watches for to turn around and begin the up-pass.
Turning around: completeUnitOfWork
When beginWork returns null, performUnitOfWork hands off to completeUnitOfWork. This is the function that walks back up, and it makes a simple three-way decision at every Fiber:
- Call
completeWorkon the current Fiber (finish it). - Does it have a sibling? If yes, point the walk at that sibling — which restarts the down-pass on a fresh branch.
- No sibling? Move up to the parent (
.return) and repeat from step 1.
It keeps doing this in a do…while loop until it climbs above the root, at which point the whole render is done.
The real code
function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// ========== CALL completeWork FOR THIS FIBER ==========
let next = completeWork(current, completedWork, renderLanes);
if (next !== null) {
// Exception: completeWork found suspended work, need to unwind
workInProgress = next;
return;
}
// ========== BUBBLE UP EFFECTS TO PARENT ==========
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// ========== HAS SIBLING: Process it next ==========
workInProgress = siblingFiber;
return; // Exit, will call beginWork on sibling
}
// ========== NO SIBLING: Move to parent ==========
completedWork = returnFiber; // Go up
workInProgress = completedWork; // Update global pointer
} while (completedWork !== null); // Until we reach root
// Reached root, mark as completed
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
This is the engine behind the rhythm you saw in the trace. When a Fiber has a sibling, the loop returns and the outer work loop goes back to beginWork for that sibling — that is why steps 7→8 in the trace jump from finishing <Header> straight to starting <Content>. When there is no sibling, it climbs to the parent and the parent gets its completeWork.
Coming up: what completeWork does
If beginWork decides the shape of the tree, completeWork builds the real thing. For a host element like <div>, its jobs are:
- Create the DOM node for a newly mounted element (or, for an update, diff its props).
- Append its children — which already exist, because their
completeWorkran first on the way up. - Bubble effect flags up to the parent, so the next phase knows where the changes are.
The real code
function completeWork(current, workInProgress, renderLanes) {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case HostComponent: { // DOM elements like <div>, <span>
const type = workInProgress.type; // 'div', 'span', etc.
if (current !== null && workInProgress.stateNode != null) {
// ========== UPDATE EXISTING DOM NODE ==========
updateHostComponent(
current,
workInProgress,
type,
newProps,
renderLanes,
);
} else {
// ========== CREATE NEW DOM NODE ==========
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress,
);
// Append all children (already created by their completeWork)
appendAllChildren(instance, workInProgress, false, false);
// Store DOM node reference
workInProgress.stateNode = instance;
// Prepare initial props
if (
finalizeInitialChildren(
instance,
type,
newProps,
currentHostContext,
)
) {
markUpdate(workInProgress);
}
}
bubbleProperties(workInProgress); // <- Bubble effect flags up
return null;
}
case FunctionComponent: {
// Function components have no DOM node
bubbleProperties(workInProgress);
return null;
}
// ... other fiber types ...
}
}
Notice that a FunctionComponent creates no DOM node at all — your <App> is not an HTML element, so its completeWork only bubbles flags and returns. The real DOM nodes come from HostComponent Fibers (<div>, <span>, and friends).
That last call, bubbleProperties, is small but important. It OR-combines every child's flags into the parent's subtreeFlags. By the time the walk reaches the root, the root's subtreeFlags is a summary of every change anywhere in the tree — which lets a later phase skip clean branches in one check instead of re-walking everything.
Show the full bubbleProperties source
function bubbleProperties(completedWork) {
let subtreeFlags = NoFlags;
let child = completedWork.child;
// ========== COLLECT FLAGS FROM CHILDREN ==========
while (child !== null) {
subtreeFlags |= child.subtreeFlags; // Child's descendants' flags
subtreeFlags |= child.flags; // Child's own flags
child = child.sibling;
}
// ========== BUBBLE UP TO PARENT ==========
completedWork.subtreeFlags = subtreeFlags;
}
// Example:
// <div> has 3 children:
// <p> has Update flag
// <span> has PlacementAndUpdate flag
// <img> has Placement flag
//
// After bubbleProperties(<div>):
// <div>.subtreeFlags = Update | PlacementAndUpdate | Placement
//
// This allows the commit phase to skip entire clean subtrees in O(1)!
The whole walk in slow motion
Here is one render of a tiny tree, step by step, with timestamps. Watch how completeWork on the leaves fires first, and each parent's DOM node is only created after its children's DOM nodes already exist.
Rendering:
<App>
└─ <div id="container">
├─ <p>Hello</p>
└─ <span>World</span>
T=0ms: beginWork(<App>)
├─ Call App() function
├─ reconcileChildren() creates <div> fiber
└─ Return <div> fiber (first child)
T=1ms: beginWork(<div>)
├─ reconcileChildren() creates <p> and <span> fibers
└─ Return <p> fiber (first child)
T=2ms: beginWork(<p>)
├─ reconcileChildren() creates "Hello" text fiber
└─ Return text fiber
T=3ms: beginWork("Hello")
├─ Text fiber has no children
└─ Return null <-- NO MORE CHILDREN
T=4ms: completeWork("Hello") <-- FIRST completeWork!
├─ createTextNode("Hello") -> detached text node
└─ workInProgress.stateNode = textNode
T=5ms: completeWork(<p>)
├─ createElement('p') -> detached <p>
├─ appendAllChildren: p.appendChild(textNode)
├─ workInProgress.stateNode = p
└─ bubbleProperties()
Now check sibling -> found <span>
T=6ms: beginWork(<span>)
└─ Return "World" text fiber
T=7ms: beginWork("World")
└─ Return null
T=8ms: completeWork("World")
└─ createTextNode("World") -> detached text node
T=9ms: completeWork(<span>)
├─ createElement('span')
├─ appendAllChildren: span.appendChild(textNode)
└─ workInProgress.stateNode = span
No more siblings -> go to parent
T=10ms: completeWork(<div>)
├─ createElement('div')
├─ appendAllChildren:
│ div.appendChild(p) <-- child DOM already exists!
│ div.appendChild(span) <-- child DOM already exists!
├─ diffProperties() -> updateQueue = ['id', 'container']
├─ workInProgress.stateNode = div
└─ bubbleProperties() -- collect flags from <p> and <span>
T=11ms: completeWork(<App>)
├─ FunctionComponent -- no DOM node created
├─ bubbleProperties() -- collect all descendant flags
└─ Done!
RESULT: complete DOM tree built in detached memory
fiber.stateNode points to each node
browser has painted NOTHING yet
Why build DOM bottom-up? Because there is no other order that works. To run div.appendChild(p), the <p> node must already exist. Children-first is the only sequence where a parent always has real children to attach.
The real code
// CORRECT (bottom-up):
completeWork(<p> child) // -> create <p>, append "Hello" text
completeWork(<div> parent) // -> create <div>, append <p> child -- child already created!
// WRONG (top-down):
beginWork(<div> parent) // -> create <div>, but children do not exist yet!
beginWork(<p> child) // -> create <p>, but how to append to parent?
That last line — "browser has painted NOTHING yet" — is the bridge to the next lesson. The whole walk you just learned is React's render phase: it computes a finished tree in memory but touches nothing on screen. Putting it on screen is a separate step. That split is the subject of 2.2 Render vs Commit.