Skip to main content

How beginWork works

Begin works is the function in React that will render your application.

We invoked it in the previous section like this:

// simplified
function performUnitOfWork(unitOfWork: Fiber): void {
const current = unitOfWork.alternate;

// current: the original fiber: what's visible on the screen
// unitOfWork: the alternate fiber: the current ongoing render
// renderLanes: were globally assigned in prepareFreshStack
const next = beginWork(current, unitOfWork, renderLanes);

// ... rest of the code
}

Signature

beingWork is defined as follows:

function beginWork(
current: Fiber | null, // the current fiber's tree
workInProgress: Fiber, // the alternate
renderLanes: Lanes,
): Fiber | null { /* [Not Native Code] */ }

It returns the next unit of work, we will see how it calculates it. The next unit of work will be the alternate of the next current fiber.

Implementation

React components can be rendered several times in their lifetime, the alternate is created for each render that occurs as a draft of the next version of the component's output.

beginWork's simplified version looks like this:

function beginWork(
current,
wip,
lanes
) {
if (current !== null) {
// this component is updating
} else {
// this component renders for the first time

}
}

Attempt early bailout if applicable

So beginWork will first check if we deal with a re-render, this is not the case when coming from root.render() but we will get into it anyway. Because this code path is visited at every render.

note

Even when coming from root.render, the HostRoot fiber will have the alternate created since we created it when preparing a fresh stack. But not the rest of the tree.

So, when the alternate already exists:

  • Reference the oldProps (current.memoizedProps) and newProps ( alternate(wip).pendingProps):

    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;
  • If the oldProps and newProps aren't the same (meaning a triggered render from the parent component) or the legacy context changed, then it mark the component as did receive update.

  • When props did change, it will check if there is a scheduled update or some context changed:

    • The component received an update means that the alternate lanes intersect with the renderLanes (do a pause a think about it).
    • Contexts are stored as fiber.dependencies in a linked list, so it will iterate through all of them and compare the context's value.

    When nothing changed, React will attempt to bail out the render for this component and its children if possible. We will see the bailout in its dedicated section.

  • When the current is null (first render of a component), then it will mark the component as it did not receive an update, then it will perform some hydration related stuff. This is out of our scope for now.

Let's put the previous words into code:

// simplified
function beginWork(
current: Fiber | null, // the painted fiber if any (garanteed for HostRoot)
workInProgress: Fiber, // the pending rendered fiber
renderLanes: Lanes,
) {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;

if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceivedUpdate = true;
} else {
if (
hasScheduledUpdateOrContext(current, renderLanes) &&
// more on this later
(workInProgress.flags & DidCapture === NoFlags)
) {
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}
}
} else {
didReceiveUpdate = false;
// out of our scope code
}
}
note

As a recap, beginWork would attempt to bail out the work if not needed. This is not the only work bailout on React, we will see it several times again.

Render the components

Right next, React will perform a huge switch statement over workInProgress.tag.

The goal of this switch is to redirect to the right function that will perform the render on the current fiber.

We will see the workTags in details in the next section, so for now, let's just scratch the surface when coming from root.render(). Then in the next section, we will dive into the work tags and how they work before visiting the render of each one of them.

// simplified
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {

// previous code

switch(workInProgress.tag) {
// case FunctionComponent:
// case ClassComponent:
// case IndeterminateComponent:
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
// case HostPortal:
// case HostComponent:
// case HostText:
// case Fragment:
// case Mode:
// case ContextConsumer:
// case ContextProvider:
// case ForwardRef:
// case Profiler:
// case SuspenseComponent:
// case MemoComponent:
// case SimpleMemoComponent:
// case LazyComponent:
// case IncompleteClassComponent:
// case DehydratedFragment:
// case SuspenseListComponent:
// case ScopeComponent:
// case OffscreenComponent:
// case LegacyHiddenComponent:
// case CacheComponent:
// case TracingMarkerComponent:
// case HostHoistable:
// case HostSingleton:
}

throw new Error(
`Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
'React. Please file an issue.',
);
}

So as you see, there is a case for every supported tag.

For the very first render while having the HostRoot fiber, we'll return updateHostRoot(current, workInProgress, renderLanes).

Let's scratch updateHostRoot too.

PS: This illustrates the first ever render of the root.


function updateHostRoot(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// we will ignore this for now
pushHostRootContext(workInProgress);

// defensive guard; the root fiber always has the current and alternate
if (current === null) {
throw new Error('Should have a current fiber. This is a bug in React.');
}

const nextProps = workInProgress.pendingProps; // nextProps is null

const prevState = workInProgress.memoizedState; // { element: null }

const prevChildren = prevState.element; // prevChildren = null

cloneUpdateQueue(current, workInProgress);
processUpdateQueue(workInProgress, nextProps, null, renderLanes);

// let's stop here for now, since the next code would assume that
// the updateQueue processing is done and would use properties accordingly
// we will continue the code after explaining clone and process the queue
}

Cloning the updateQueue

The updateQueue was defined in how createRoot works and its pending share queue was populated during how root.render works.

Now, and assuming we reach this path from the first render and during the runtime of the app, this function will attach a new cloned updateQueue to the alternate if it has the same queue as the current fiber.

function cloneUpdateQueue<State>(
current: Fiber,
workInProgress: Fiber,
): void {
// Clone the update queue from current. Unless it's already a clone.
const queue: UpdateQueue<State> = workInProgress.updateQueue;
const currentQueue: UpdateQueue<State> = current.updateQueue;

if (queue === currentQueue) {
const clone: UpdateQueue<State> = {
baseState: currentQueue.baseState,
firstBaseUpdate: currentQueue.firstBaseUpdate,
lastBaseUpdate: currentQueue.lastBaseUpdate,
shared: currentQueue.shared, // shared.pending is what's intersting here
callbacks: null,
};
workInProgress.updateQueue = clone;
}
}

Processing the update queue

This process is long and very complex, I will simplify it a lot and move on because it is not that interesting at this stage, we will see it again.

This cloning and processing paths are achievable from HostRoots, ClassComponents and the upcoming experimental CacheComponents.

The pending queue being cyclic and has at most two entries, the most recent as the last. It will be disconnected and then processed in a big while(true) loop.

This section will be moved into the annex, it is highly complex and would take a lot of time to be explained.

Here is a simplified version that would allow us continuing the render phase:


// coming from root.render(), we have only one update in a cyclic way
prepareTheOrderOfUpdatesToProcess();

let update = firstBaseUpdate;
let newState = queue.baseState;
do {
const queue = workInProgress.updateQueue;
newState = getStateFromUpdate(wip, queue, update, newState, props);

if (update.callback) {
queue.callbacks.push(update.callback);
}

update = update.next;

// no pending update
if (update === null) {
if (queue.shared.pending === null) {
break;
} else {
update = appendPendingUpdates();
}
}
} while (true);

workInProgress.lanes = newLanes;
workInProgress.memoizedState = newState;

The getStateFromUpdate will switch over the update.tag (update state for now), which will result in a state with { element } containing the children we gave to root.render().

Let's now get back to updateHostRoot:


function updateHostRoot(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// we will ignore this for now
pushHostRootContext(workInProgress);

// defensive guard; the root fiber always has the current and alternate
if (current === null) {
throw new Error('Should have a current fiber. This is a bug in React.');
}
const nextProps = workInProgress.pendingProps; // nextProps was null here
const prevState = workInProgress.memoizedState; // { element: null }
const prevChildren = prevState.element; // prevChildren = null

cloneUpdateQueue(current, workInProgress);
processUpdateQueue(workInProgress, nextProps, null, renderLanes);

// we are here now

// nextState = {element: children, isDehydrated: false, cache: {...} }
const nextState: RootState = workInProgress.memoizedState;
const root: FiberRoot = workInProgress.stateNode;
const nextChildren = nextState.element;

if (nextChildren === prevChildren) {
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
reconcileChildren(current, workInProgress, nextChildren, renderLanes);

return workInProgress.child;
}

bailoutOnAlreadyFinishedWork and reconcileChildren are key functions during render and are called from many places, they deserve their own chapter.

During the first render, we won't pass through bailoutOnAlreadyFinishedWork, but reconcileChildren is a key here! So let's scratch it too:


export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
// initial mount of fibers, HostRoot won't pass here
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress, // the returnFiber (the parent of the children)
current.child, // the current painted first child
nextChildren, // the new first child
renderLanes, // the lanes used during this render (DefaultLane from root.render)
);
}
}

When processing the HostRoot fiber, the current always exists and thus reconcileChildFibers will call reconcileChildFibers which resets thenable counter then calls reconcileChildFibersImpl with the same arguments.

How reconcileChildFibersImpl works

This function is responsible for rendering the children, like we saw earlier the children can be of many forms, such as an array of elements, string... and so on.

function reconcileChildFibersImpl(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// [Not Native Code]
}

Let dive into this function step by step:

  1. Skip over unkeyed top level fragment

    First, React will verify if you top level child is a Fragment without a key props. If that's the case, then it will skip over that Fragment

    // unkeyed top level fragment is know like this:
    const isUnkeyedTopLevelFragment =
    typeof newChild === 'object' &&
    newChild !== null &&
    newChild.type === REACT_FRAGMENT_TYPE &&
    newChild.key === null;

    if (isUnkeyedTopLevelFragment) {
    newChild = newChild.props.children;
    }
  2. When the given children are a non-null object then it will switch over the $$typeof property. Dan gave a great blog post [about this property].(https://overreacted.io/why-do-react-elements-have-typeof-property/)

    if (typeof newChild === 'object' && newChild !== null) {
    switch(newChild.$$typeof) {
    case REACT_ELEMENT_TYPE: {
    return placeSingleChild(
    reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes)
    );
    }
    case REACT_PORTAL_TYPE: {
    return placeSingleChild(
    reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
    );
    }
    case REACT_LAZY_TYPE: {
    // ignore for now
    }
    }
    }
    if (isArray(newChild)) {
    // this will be delayed to the future
    return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
    }

    if (getIteratorFn(newChild)) {
    // this will be delayed to the future
    return reconcileChildrenIterator(returnFiber, currentFirstChild, newChild, lanes);
    }

    reconcileChildrenArray and reconcileChildrenIterator will be revisited later.

  3. If the children are an object when a .then function property (async components 😉), then the promise will be unwrapped and its result is given to reconcileChildFibersImpl again:

    if (typeof newChild.then === "function") {
    return reconcileChildFibersImpl(
    returnFiber,
    currentFirstChild,
    unwrapThenable(newChild),
    lanes
    );
    }
    danger

    Async components are experimental and not well-supported in the client since any render will output a new Promise and thus remove the previous tree. Don't use them without a caching strategy (they were originally designed for the server were they are invoked once per request.).

tip

As you may have noticed already, the reconciliation passes before the actual render.

The goal of the reconciliation is to port the next tree to the alternate that we created from the current.

At this stage coming from root.render() there is not even a Fiber created for out children, so the first step will be to create it.

how reconcileSingleElement works

This is actually a part of the reconciliation which has its own section.

The first thing this function does it to verify from the currentFirstChild if key or type changed then it will remove the child by adding it to a deletions property in the parentFiber. This will keep track of deleted fibers so that we can invoke their cleanup effects in the commit phase.

Next, this function will create and return a new fiber for our application:

const created = createFiberFromElement(element, returnFiber.mode, lanes);

When coming from root.render(), the first fiber created by this will be the third of our journey:

  • The first is the current fiber attached to the fiber root
  • The second is its alternate
  • The third (at least) is for the first child we gave to root.render()
info

By now, we have two unexplained major sections:

  1. The big switch-case in the beginWork function, it will be explained in the how rendering works section.
  2. How reconcileSingleElement works and how fibers are created, which will be explained in the how the reconciliation works section.

To avoid making this section very huge, we will move them next.

Recap

beginWork is called inside performUnitOfWork(workInProgress) which will be responsible for reconciling the children for the next tree before rendering it.

// simplified in renderSync
while(unitOfWork !== null) {
performUnitOfWork(unitOfWork);
}


function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;

if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceivedUpdate = true;
} else {
if (
hasScheduledUpdateOrContext(current, renderLanes) &&
// more on this later
(workInProgress.flags & DidCapture === NoFlags)
) {
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}
}
} else {
didReceiveUpdate = false;
// out of our scope code
}

switch(workInProgress.tag) {
// case FunctionComponent:
// case ClassComponent:
// case IndeterminateComponent:
case HostRoot:
return updateHostRoot(current, workInProgress, renderLanes);
// case HostPortal:
// case HostComponent:
// case HostText:
// case Fragment:
// case Mode:
// case ContextConsumer:
// case ContextProvider:
// case ForwardRef:
// case Profiler:
// case SuspenseComponent:
// case MemoComponent:
// case SimpleMemoComponent:
// case LazyComponent:
// case IncompleteClassComponent:
// case DehydratedFragment:
// case SuspenseListComponent:
// case ScopeComponent:
// case OffscreenComponent:
// case LegacyHiddenComponent:
// case CacheComponent:
// case TracingMarkerComponent:
// case HostHoistable:
// case HostSingleton:
}

throw new Error(
`Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
'React. Please file an issue.',
);
}

updateHostRoot will process the updateQueue of our top level root object, which will result in a new tree to be rendered. Which is what we will see in the next section.