In previous articles, we learned that the two most important processes during the render phase are beginWork
and completeWork
. This article will explore the different behaviors of beginWork
during the initialization and update phases. I will first identify their commonalities and then analyze them step-by-step through the source code, ultimately clarifying this process.
Both the initialization and update processes fundamentally rely on three basic elements:
current: The current Fiber node
workInProgress: The Fiber node being constructed
renderLanes: The priority level of the current update task
beginWork
operates on a specific Fiber node, processing only the current node at each step. After processing, it continuously looks for its child nodes until it encounters a null reference, signaling the end of the current beginWork
cycle. By the end of this section, you will gain a deeper understanding of the following questions:
What are the similarities and differences of
beginWork
in mount and update scenarios?How does React reuse Fiber nodes?
Why is there an IndeterminateComponent intermediate state?
What is the state calculation mechanism when there are multiple priorities?
And more...
II. Initialization Scenario
The process of beginWork
itself is not difficult; rather, it is the extensive content and numerous branches that make it complex. Therefore, it suffices to grasp the classic scenarios of beginWork
. The source code for beginWork
is as follows:
function beginWork(current, workInProgress, renderLanes) { if (current !== null) { // Indicates the update phase // The mount phase can be ignored here since it won't reach this block } // Clear lanes after reconciling workInProgress.lanes = NoLanes; // Determine based on tag switch (workInProgress.tag) { case IndeterminateComponent: { // Intermediate state for functional components return mountIndeterminateComponent( current, workInProgress, workInProgress.type, renderLanes ); } case LazyComponent: { // Asynchronous component var elementType = workInProgress.elementType; return mountLazyComponent( current, workInProgress, elementType, renderLanes ); } case HostRoot: return updateHostRoot(current, workInProgress, renderLanes); case HostComponent: return updateHostComponent(current, workInProgress, renderLanes); case HostText: return updateHostText(current, workInProgress); case SuspenseComponent: return updateSuspenseComponent(current, workInProgress, renderLanes); case Fragment: return updateFragment(current, workInProgress, renderLanes); case Mode: return updateMode(current, workInProgress, renderLanes); case Profiler: return updateProfiler(current, workInProgress, renderLanes); case ContextProvider: return updateContextProvider(current, workInProgress, renderLanes); case ContextConsumer: return updateContextConsumer(current, workInProgress, renderLanes); ... } ... }
While the logic may seem overwhelming, the basic structure is as follows:
function beginWork(current, workInProgress, renderLanes) { if (isUpdatePhase) { if (canReuseFiber) { return newFiberBasedOnOriginalFiber; } cannotReuse; // Mark the Fiber } // For the mount phase, go directly here since everything needs to be rebuilt const tag = workInProgress.tag; // Branch based on the type of Fiber node if (fiberNodeIsRootFiberType) { return xxx; } if (fiberNodeIsFunctionalComponent) { return xxx; } if (fiberNodeIsNativeDOMNode) { return xxx; } ... }
During the mount phase, we primarily need to understand the initialization of a few key types of Fiber nodes:
HostRoot: This node will always be encountered during both initialization and updates.
mountIndeterminateComponent: This is encountered when initializing a custom component.
HostComponent: This is the basis for creating actual DOM nodes and is fundamental for UI construction.
We will delve deeper into other nodes in a later theoretical discussion.
HostRoot
Every render cycle starts from the RootFiber node, which corresponds to HostRoot because the tag attribute of RootFiber is 3. Based on what we previously learned, it falls under the HostRoot type of Fiber, as shown below:
export const FunctionComponent = 0; export const ClassComponent = 1; export const IndeterminateComponent = 2; export const HostRoot = 3; export const HostPortal = 4; export const HostComponent = 5; export const HostText = 6; export const Fragment = 7; ...
Therefore, during the mount phase, we directly call updateHostRoot(current, workInProgress, renderLanes)
. This node is unique in that it is the only one with a current
in the mount phase. At this moment, the structure in memory consists of one current
and one workInProgress
node. Let's see what happens with HostRoot:
function updateHostRoot(current, workInProgress, renderLanes) { var nextProps = workInProgress.pendingProps; var prevState = workInProgress.memoizedState; var prevChildren = prevState.element; // Separate the updateQueue cloneUpdateQueue(current, workInProgress); // Calculate state, which basically transfers the element from updateQueue to memoizedState processUpdateQueue(workInProgress, nextProps, null, renderLanes); // Now memoizedState contains the <App/> ReactElement node var nextState = workInProgress.memoizedState; var nextChildren = nextState.element; // <App/> ... // Create the first component node based on the current <App/> reconcileChildren(current, workInProgress, nextChildren, renderLanes); return workInProgress.child; }
The primary job of updateHostRoot
is to produce child Fiber nodes for workInProgress
. This involves some steps, such as:
The first update task created during the initialization process is stored in the updateQueue
of the RootFiber node. Now, it needs to be extracted and placed into memoizedState
, then the <App>
ReactElement node is extracted for the next step to generate its child nodes.
Next, reconcileChildren
is called to generate its child nodes:
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) { if (current === null) { // Initialization workInProgress.child = mountChildFibers( workInProgress, null, nextChildren, renderLanes ); } else { // Update workInProgress.child = reconcileChildFibers( workInProgress, current.child, nextChildren, renderLanes ); } }
For the HostRoot node, it is the only one that has a current
during initialization. All other nodes will follow the first branch during initialization. In reconcileChildFibers
, there are two scenarios: single nodes and multiple nodes. If nextChildren
is an object, a single Fiber will be generated. If it is an array, a Fiber linked list will be created, linking according to the rules of the Fiber tree. The return is the first node; generally, we will only pass a single node at the root, so we follow the logic to generate a single Fiber node in reconcileSingleElement
:
function reconcileSingleElement( returnFiber, currentFirstChild, // During initialization, this is null since there is only one RootFiber node and no child nodes element, lanes ) { var key = element.key; var _created = createFiberFromElement( element, returnFiber.mode, lanes ); _created.ref = coerceRef(returnFiber, currentFirstChild, element); _created.return = returnFiber; return _created; }
When creating a Fiber node, if the encountered root component is a functional component, it will be marked with IndeterminateComponent
. If it is a class component, it will be marked as ClassComponent
. So how do we distinguish between a class component and a functional component?
function shouldConstruct$1(Component) { var prototype = Component.prototype; return !!(prototype && prototype.isReactComponent); }
This is the basis for the judgment because class components are classes that extend React.Component
, while functional components are pure functions, allowing us to distinguish between them.
The above outlines the reconciliation process for HostRoot, resulting in the production of its child node. If the child node is a Fiber representing a functional component class, it will be returned and assigned to the new workInProgress
, entering the next round of beginWork
.
IndeterminateComponent
The IndeterminateComponent
class in Fiber essentially represents a functional Fiber node that React initially recognizes as a function component. You may wonder why there is an intermediate state like this. Let’s delve into why this exists.
The purpose of reconciling IndeterminateComponent
during the initialization phase is to generate its child nodes. To do this, the function component needs to be invoked, as this is the only way to retrieve the latest ReactElement
, and any hooks within the function component will also be executed in the process.
Code Breakdown
function mountIndeterminateComponent( _current, // For mounts, this is null workInProgress, Component, renderLanes ) { var props = workInProgress.pendingProps; var value; // Logic for class components that don’t extend React.Component setIsRendering(true); // Invoke the function component value = renderWithHooks( null, workInProgress, Component, props, context, renderLanes ); setIsRendering(false); workInProgress.flags |= PerformedWork; // Tagging // If it returns an instance of a class component if ( typeof value === "object" && value !== null && typeof value.render === "function" && value.$$typeof === undefined ) { // Branch 1 workInProgress.tag = ClassComponent; // Mark as a class component workInProgress.memoizedState = value.state !== undefined ? value.state : null; initializeUpdateQueue(workInProgress); adoptClassInstance(workInProgress, value); mountClassInstance(workInProgress, Component, props, renderLanes); return finishClassComponent( null, workInProgress, Component, true, hasContext, renderLanes ); } else { // Branch 2 - For regular function components workInProgress.tag = FunctionComponent; return workInProgress.child; } }
Why Does mountIndeterminateComponent
Exist?
This intermediate state exists for performance optimization in certain scenarios. Since function components are user-provided, users could write something like the following:
// Class class ClassComponent extends React.Component { constructor(props) { super(props); this.state = { num: 1 }; } render() { const { num } = this.state; const onClick = () => { this.setState({ num: num + 1 }); }; return ( <div> <button onClick={onClick}>{num}</button> </div> ); } } // Function const FunctionComponent = () => { const [count, setCount] = React.useState(1); const onClick = () => { setCount(count + 1); }; const instance = new ClassComponent(); return instance; }; ReactDOM.createRoot(<FunctionComponent />).render(container);
In this scenario, mountIndeterminateComponent
will enter branch 1, where beginWork
marks this function component as a class component. The rationale is that their state can be treated as the node’s state. This approach optimizes performance by avoiding an extra cycle of beginWork
that would otherwise be required if it were treated as two separate nodes.
In the next cycle, this function component will be handled as a class component. Function components that return regular ReactElement
objects proceed through branch 2 of mountIndeterminateComponent
, generating their child nodes. However, before that, an important step occurs: calling the render function renderWithHooks
. This function invocation triggers the user-written components in React, along with the corresponding hooks.
Code Breakdown
function renderWithHooks( current, // For initialization, this is null workInProgress, Component, // The component props, secondArg, nextRenderLanes ) { renderLanes = nextRenderLanes; currentlyRenderingFiber$1 = workInProgress; workInProgress.memoizedState = null; workInProgress.updateQueue = null; workInProgress.lanes = NoLanes; if (current && current.memoizedState) { ReactCurrentDispatcher$1.current = HooksDispatcherOnUpdateInDEV; } else { ReactCurrentDispatcher$1.current = HooksDispatcherOnMountInDEV; } var children = Component(props, secondArg); // Execute the render function ReactCurrentDispatcher$1.current = ContextOnlyDispatcher; return children; }
ReactCurrentDispatcher$1.current
is the object referenced when hooks are called. It is only set to the correct position before invoking the function; otherwise, an error function list is returned.
During hook calls, we get the latest state. This will be covered further in the hooks section, but for now, understand that we obtain the latest ReactElement
object, which initiates the child node generation process in reconcileChildren
, as previously analyzed. This process aims to create a Fiber node.
HostComponent
A function component generating child nodes typically produces a native DOM element node, classified as a HostComponent
. Let’s look at the reconciliation flow for this type of node. This node, created during the reconciliation of a function component, is processed in updateHostComponent
.
function updateHostComponent(current, workInProgress, renderLanes) { var type = workInProgress.type; var nextProps = workInProgress.pendingProps; var prevProps = current ? current.memoizedProps : null; // For initialization, this is null var nextChildren = nextProps.children; reconcileChildren(current, workInProgress, nextChildren, renderLanes); return workInProgress.child; }
The Fiber type for native DOM elements is simple, involving only the generation of child nodes, which directly enters the reconcileChildren
step to start creating Fiber nodes.
The core of mountChildFibers
is creating a single child Fiber node or a Fiber-linked list of child nodes.
Summary
During the initialization phase, beginWork
mainly creates Fiber nodes, optimizes for function components, executes render functions, triggers hooks, calculates state, and tags nodes accordingly.
Update Scenario:
Fiber Reuse
Once our application has completed initialization, it enters the update phase. During updates, each node from the root proceeds through the beginWork
process, but not every node needs a new fiber node creation. To optimize performance, React tries to reuse previous fiber nodes. Here’s how that happens:
function beginWork(current, workInProgress, renderLanes) { if (current !== null) { // Update phase // Check if this node can be reused if (canReuse) { return reusableNode / clone; } } // Behaves as in the mount phase }
Through a branch that filters out reusable nodes, React determines whether a node should be reused by checking specific conditions:
function checkScheduledUpdateOrContext(current, renderLanes) { var updateLanes = current.lanes; return includesSomeLane(updateLanes, renderLanes); }
Before entering beginWork
, flags are set on the fiber node's ancestor nodes to indicate updates, allowing reuse for any nodes that don’t match this specific flag.
Single vs. Multiple Nodes
In the update phase, there are two possible scenarios for each level: a single node or multiple nodes. These cases are handled in reconcileChildFibers
:
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) { if (typeof newChild === "object" && newChild !== null) { switch (newChild.$$typeof) { case REACT_ELEMENT_TYPE: return placeSingleChild( reconcileSingleElement( // Single node scenario returnFiber, currentFirstChild, newChild, lanes ) ); } if (isArray(newChild)) { return reconcileChildrenArray( // Multiple nodes returnFiber, currentFirstChild, newChild, lanes ); } } }
If newChild
is an array, reconcileChildrenArray
is triggered, initiating the diff algorithm. We’ll explore the diff algorithm in a separate discussion. Here, we’ll focus on single-node reuse—how React reuses it through either cloning the original fiber or directly referencing it.
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) { var key = element.key; var child = currentFirstChild; while (child !== null) { if (child.key === key) { // Same key var elementType = element.type; // Tag check if (child.elementType === elementType) { // Same tag deleteRemainingChildren(returnFiber, child.sibling); // Reuse the existing node var _existing = useFiber(child, element.props); _existing.ref = coerceRef(returnFiber, child, element); _existing.return = returnFiber; return _existing; } break; } child = child.sibling; } }
Fiber reuse happens if two conditions are met:
Same key
Same tag
Reusing the fiber involves cloning it instead of directly referencing it:
function useFiber(fiber, pendingProps) { // `fiber` is the old fiber node, `pendingProps` are the new props var clone = createWorkInProgress(fiber, pendingProps); clone.index = 0; clone.sibling = null; return clone; }
State Calculation
During updates, React recalculates the latest state based on the current one. This essential logic occurs in processUpdateQueue
, which we’ll analyze step-by-step.
Step 1: beginWork
retrieves the updateQueue
, which, as previously discussed, is structured as a circular linked list.
Suppose there are four update tasks in this render: A1, B2, C1, and D2 (where "1" indicates high priority and "2" indicates low priority). The current render only processes high-priority updates.
Step 2: Split the list.
Step 3: Iterate through the list and calculate the state.
During iteration, only updates matching the current priority are calculated. Others are placed in firstBaseUpdate
and lastBaseUpdate
on the fiber for the next update. For example, C1’s priority is set to 0 for future processing. Therefore, the calculated state includes A and C.
When the next update priority is 2, it will traverse firstBaseUpdate
, calculating based on baseState
.
Summary
In the update phase, beginWork
reuses nodes whenever possible, while state calculations skip lower-priority states, recalculating them in the next scheduling cycle.