In-Depth Understanding of React— The Complete Process of beginWork

Time: Column:Mobile & Frontend views:195

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:

  1. current: The current Fiber node

  2. workInProgress: The Fiber node being constructed

  3. 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:

In-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

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-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

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:

  1. Same key

  2. 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.

In-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

Step 3: Iterate through the list and calculate the state.

In-Depth Understanding of React— The Complete Process of beginWork

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.

In-Depth Understanding of React— The Complete Process of beginWork

Summary

In the update phase, beginWork reuses nodes whenever possible, while state calculations skip lower-priority states, recalculating them in the next scheduling cycle.