In-depth Understanding of React: The Commit Phase

Time: Column:Mobile & Frontend views:211

If we think of React's entire workflow as cooking a dish, then the render phase is like preparing the recipe, while the real work is done in the commit phase. In this phase, class components execute various lifecycle hooks, functional components run Effect hooks, and pure native components perform actual DOM operations. Thus, the commit phase is when work is handed off to React, fulfilling its core task—building the user interface.

In this article, we’ll explore the commit phase in detail, analyzing its different parts to give you a thorough understanding of React’s execution flow. This knowledge will not only help you answer interview questions with confidence but also tackle complex challenges in your job. By the end of this article, you’ll gain a deeper understanding of:

  1. What are side effects?

  2. What stages does the commit phase include?

  3. How does the asynchronous execution of useEffect work?

  4. And more...


Side Effects (Flags)

If we were to summarize the render phase, its core functions are:

  1. Building the fiber tree

  2. Applying side-effect flags

The first point is relatively straightforward, but what exactly are the side effects? Below is an abbreviated list of the most commonly encountered side effects:

export type Flags = number;

export const NoFlags = /*                      */ 0b0000000000000000000000000000;
// Movement, addition
export const Placement = /*                    */ 0b0000000000000000000000000010;
// Update
export const Update = /*                       */ 0b0000000000000000000000000100;
// Deletion
export const ChildDeletion = /*                */ 0b0000000000000000000000010000;
// Content reset
export const ContentReset = /*                 */ 0b0000000000000000000000100000;
// Callback
export const Callback = /*                     */ 0b0000000000000000000001000000;
// Reference
export const Ref = /*                          */ 0b0000000000000000001000000000;
// Snapshot
export const Snapshot = /*                     */ 0b0000000000000000010000000000;
// Hook
export const Passive = /*                      */ 0b0000000000000000100000000000;

// Lifecycle-related flags
export const LifecycleEffectMask =
  Passive | Update | Callback | Ref | Snapshot;

// Snapshot-related
export const BeforeMutationMask = Update | Snapshot;

// Change, mutation-related
export const MutationMask = Placement | Update | ChildDeletion | ContentReset | Ref;

// Layout-related
export const LayoutMask = Update | Callback | Ref;

// `useEffect`-related
export const PassiveMask = Passive | Visibility | ChildDeletion;

Placement

Case 1:
When a component initializes, the root component is flagged with Placement. For example:

const App = () => { ... }

React.createRoot(document.getElementById('root')).render(<App />);

If we render the App component, only the root App fiber node will have the Placement flag. Its child fiber nodes won’t have this flag, so during commit, the offscreen DOM tree only needs to be mounted under #root.

Case 2:
When elements in a list are reordered, requiring React to move nodes. For example:

Old Structure:

<div key='a'>A</div>
<div key='b'>B</div>

New Structure:

<div key='b'>B</div>
<div key='a'>A</div>

If a user rearranges the list as shown, the Diff algorithm will determine that the div-a fiber node should move up one position, and it will receive a Placement flag.

Update

Case 1:
A class component with componentDidUpdate defined will receive this flag if the execution conditions are met. This generally happens when there’s a difference between the old and new state or props.

Case 2:
When the content of a native text or native component changes. For example:

Old Structure:

<span>
  a
  <a>hello<a/>
</span>

New Structure:

<span>
  b
  <a>hello<a/>
</span>

In this case, the a in the TextComponent fiber will receive an Update flag.

Case 3:

function finalizeInitialChildren(
    domElement,
    type, // Tag
    props,
    rootContainerInstance,
    hostContext
  ) {
    ...
    switch (type) {
      case "button":
      case "input":
      case "select":
      case "textarea":
        return !!props.autoFocus;

      case "img":
        return true;

      default:
        return false;
   }
}

If this function returns true for native components, they will also receive the Update flag.

Case 4:
Functional components using useEffect during updates also receive this flag.

ChildDeletion

Any fiber node marked for unmounting will receive the ChildDeletion flag. Unmounting occurs when a previously present component is no longer rendered.

ContentReset

Native components that initially contain only text or numerical content but switch to complex components during updates will receive this flag. For example:

Old Structure:

<div>hello</div>

New Structure:

<div>
  <span>hello</span>
</div>

Callback

Case 1:
When using the createRoot().render(JSX, callback) API, passing a callback will result in the root fiber receiving the Callback flag.

Case 2:
If a callback is provided when calling setState in a class component:

import React, { Component } from 'react';

class MyComponent extends Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0
    };
  }

  handleClick = () => {
    this.setState({ count: this.state.count + 1 }, () => {
      console.log('Count updated:', this.state.count);
    });
  };

  render() {
    return (
      <div>
        <h1>Count: {this.state.count}</h1>
        <button onClick={this.handleClick}>Increase Count</button>
      </div>
    );
  }
}

export default MyComponent;

The fiber corresponding to MyComponent will receive the Callback flag.

Ref

Case 1:
On initialization, if a component is referenced by a ref (whether native, class, or functional), it receives this flag:

const ref = React.useRef();

<h1 id="h1" ref={ref}>hello</h1>

Case 2:
During updates, if a ref changes, the flag is applied:

const App = () => {
  const [flag, setFlag] = useState(true);
  const ref1 = useRef();
  const ref2 = useRef();
  
  return (
    <div>
      <span ref={flag ? ref1 : ref2}></span>
      <button onClick={() => setFlag(!flag)}>toggle ref</button>
    </div>
  );
}

Snapshot

Case 1:
If a class component defines getSnapshotBeforeUpdate and the execution conditions are met, this flag is applied.

Case 2:
The root fiber receives this flag on initial rendering.

Passive

Case 1:
If a functional component uses useEffect and is in the initialization phase, it receives this flag.

Case 2:
The flag is applied in functional components using useSyncExternal.

These are the primary side effects you need to be familiar with at the basic level. The render phase is essentially about assigning different flags, and then the commit phase takes over.


Three Stages

From the discussion above, we now have a comprehensive understanding of various side effects and the contexts in which they occur. Next, let’s explore how the commit phase handles these side effects. React divides the commit phase into three distinct parts:

  1. beforeMutation

  2. mutation

  3. layout

This breakdown can be confirmed by examining the commitRootImpl source code:

function commitRootImpl(
    root,
    recoverableErrors,
    transitions,
    renderPriorityLevel
  ) {
    ...
    var subtreeHasEffects =
      (finishedWork.subtreeFlags &
        (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
      NoFlags; // Check if the subtree has side effects
    var rootHasEffect =
      (finishedWork.flags &
        (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
      NoFlags; // Check if the root has side effects

    if (subtreeHasEffects || rootHasEffect) { // If side effects are present, proceed with the three commit phases
      
      // 1. beforeMutation phase
      commitBeforeMutationEffects(
        root,
        finishedWork
      );

      // 2. Mutation phase
      commitMutationEffects(root, finishedWork, lanes);

      resetAfterCommit(root.containerInfo); 
      
      // Set the workInProgress tree as the current tree for the next cycle
      root.current = finishedWork; 
      
      // 3. Layout phase
      commitLayoutEffects(finishedWork, root, lanes);
    }

    var rootDidHavePassiveEffects = rootDoesHavePassiveEffects;

    if (rootDoesHavePassiveEffects) {
      // At the end, set this global variable to true, enabling flushPassiveEffects to execute smoothly in the second cycle
      rootWithPendingPassiveEffects = root; 
      pendingPassiveEffectsLanes = lanes;
    } 

    remainingLanes = root.pendingLanes;
   
    // Continue scheduling
    ensureRootIsScheduled(root, now());
    flushSyncCallbacks();
    return null;
  }

In previous discussions about the render process, we noted a key detail: each fiber node gathers the flags of its subtree as its subtreeFlags in the completeWork stage. This means that every fiber node is fully aware of the side effects in its subtree, making it straightforward to understand the logic at the start of commitRootImpl. It assesses whether the subtree has relevant side effects from the root fiber node and then proceeds accordingly, avoiding unnecessary work. If side effects are detected, it moves forward with the steps below.

BeforeMutation

The first part is beforeMutation, which iterates over all fiber nodes. When a node with the Snapshot flag is encountered, it executes the commitBeforeMutationEffectsOnFiber function to handle the corresponding flags:

  • For class components, it will call the getSnapshotBeforeUpdate method provided by the user. There is no need to check if this method exists because the presence of this flag indicates that the render phase has already validated its existence and that it meets the execution conditions.

  • If the node is of HostRoot type (RootFiber), this indicates an initialization. The Snapshot flag is only assigned to HostRoot during initialization, so the root DOM node will have its content cleared.

31.png

This is the entirety of the beforeMutation phase. Some may wonder if this type of traversal is costly in terms of performance, given that the fiber tree can be quite extensive and each commit begins from the RootFiber. In fact, due to the render phase gathering all subtree side effects, this traversal is highly efficient. For example:

30.png

If our fiber tree is large, but only Son2 has a flag, then regardless of the size of the Son1 subtree or the complexity of Component1, they will be skipped. The only nodes processed are RootFiber, App, and Son2. This efficient filtering traversal process ensures that performance remains optimal. From now on, let’s refer to this process as “filtered traversal.”

Mutation

During the mutation phase, React performs various operations according to the types of side effects. This phase handles the following side effects, as outlined below:

export const MutationMask =
  Placement |
  Update |
  ChildDeletion |
  ContentReset |
  Ref

The mutation phase also uses a filtered traversal, iterating over each fiber node marked with the above flags. When a matching node is encountered, commitMutationEffectsOnFiber is called to apply mutation operations to that fiber node.

ChildDeletion

If a fiber node has child nodes marked for removal, they are inspected sequentially:

  • If the child node being removed is a native DOM or text node, document.removeChild is called to remove its DOM element, and any referenced ref is also detached.

  • For function components, the destroy function associated with useEffect is called. Here’s an example:

import React, { useState, useEffect } from 'react';

const MyComponent = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('Component mounted');
    return () => { // Destroy function
      console.log('Component will unmount');
    };
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

export default MyComponent;

In a fiber node, each hook is stored in the updateQueue on the fiber as a linked list. The destroy function of useEffect is stored in the destroy property on the hook object, so calling each destroy function in order is straightforward.

  • For class components, the componentWillUnmount hook provided by the user is called.

Placement

This step is handled by commitPlacement, which only processes HostComponent, HostRoot, and HostPortal types of fiber nodes:

  • If it’s a native DOM element, insertOrAppendPlacementNodeIntoContainer inserts the DOM node associated with the fiber into its parent fiber’s DOM node. The actual DOM node is stored in the stateNode property of the fiber.

  • For HostRoot type, it inserts the real DOM of its child nodes into the root DOM (#root), as seen in the initialization process.

  • If the Diff algorithm identifies the fiber node for movement during updates, commitReconciliationEffects is called to move the node. The specifics of this process will be discussed in the Diff section.

Ref

If a class component or native DOM component has an existing reference, safelyDetachRef is used to clear the reference. During initialization, safelyDetachRef is not called.

ContentReset

If the node is of type Text, resetTextContent is used to clear its content:

function resetTextContent(domElement) {
    setTextContent(domElement, "");
}

var setTextContent = function (node, text) {
    if (text) {
      var firstChild = node.firstChild;

      if (
        firstChild &&
        firstChild === node.lastChild &&
        firstChild.nodeType === TEXT_NODE
      ) {
        firstChild.nodeValue = text;
        return;
      }
    }

    node.textContent = text;
};

Update

  • If the content of a native component changes, commitUpdate is called to update the DOM.

  • If the text content changes, commitTextUpdate updates the text.

  • For function components, commitHookEffectListUnmount and commitHookEffectListMount handle component content updates.

    • commitHookEffectListUnmount executes the destroy functions of updated components.

    • commitHookEffectListMount executes the mount functions of new components, such as the useEffect function of a functional component.

Summary: The mutation phase handles various DOM operations, including moving nodes, deleting nodes, clearing content, adding nodes, and executing the destroy hook for deleted components, componentWillUnmount for deleted components, and updating ref references.

Layout

In the layout phase, React executes additional lifecycle functions, filtering the fiber tree for the following side effects:

export const LayoutMask = Update | Callback | Ref

When nodes with these side effects are found, commitLayoutEffectOnFiber is used to handle them.

  1. Class Components

    • componentDidMount is executed during initialization, and componentDidUpdate during updates.

    • If a callback is passed during setState, it is executed here, with access to the latest state.

  2. Function Components

    During the layout phase, only hooks with the Layout tag are executed.

    • useLayoutEffect has a Layout tag:

      mountEffectImpl(fiberFlags, Layout /*Tag*/, create, deps);
    • useEffect has a Passive tag:

      mountEffectImpl(Passive | PassiveStatic, Passive$1, create, deps);
    • The commitHookEffectListMount function calls the useLayoutEffect hook and stores its destroy function on the fiber. As all hooks form a linked list stored in the fiber, React distinguishes between useLayoutEffect and useEffect using a tag assigned during initialization:

  3. All Components

    • If it’s a native DOM component, the real DOM node is assigned to ref.current.

    • If it’s a class component, the instance is assigned to ref.current.

    • This value is essentially the one stored in the fiber’s stateNode.

    • For any fiber type, if it has the Ref flag, commitAttachRef assigns the value to its ref.

Summary: In the layout phase, React executes componentDidMount, componentDidUpdate, useLayoutEffect, and the setState callback function, and processes ref references.


useEffect

You might have noticed that nowhere in the usual phases is useEffect mentioned as being executed. In fact, useEffect doesn’t run in any of the three primary phases. Instead, React initiates an asynchronous call in commitRootImpl before the three major phases begin.

function commitRootImpl(){
    ...
    // If there are Passive side effects in the tree, execute the following logic
    if ((finishedWork.subtreeFlags & PassiveMask) !== NoFlags || (finishedWork.flags & PassiveMask) !== NoFlags) {
      if (!rootDoesHavePassiveEffects) { // Enters here if true
        rootDoesHavePassiveEffects = true;
        pendingPassiveTransitions = transitions;
        scheduleCallback$1(NormalPriority, function () {
          flushPassiveEffects();
          return null;
        });
      }
    } 
    
    // The major phases follow below
    
    1. BeforeMutation
    
    2. Mutation
    
    3. Layout
}

In the "Understanding React" series, specifically the article on the "Scheduler," we discussed that calls initiated by the Scheduler are asynchronous. Thus, although it’s called in advance, it only executes after the three primary phases. Specifically, React schedules a NormalPriority task to execute flushPassiveEffects, which handles all Passive side effects. Let’s examine the flushPassiveEffects logic:

function flushPassiveEffects(){
  ...
  // These are the core operations
  commitPassiveUnmountEffects(root.current);
  commitPassiveMountEffects(root, root.current, lanes, transitions); 
  ...
}

Within commitPassiveUnmountEffects, commitHookEffectListUnmount is called, which executes any destroy functions that need to be run, such as the following scenario:

Within commitPassiveMountEffects, commitHookEffectListMount executes the useEffect-related hooks, specifically the create functions.

Example:

import React, { useState, useEffect } from 'react';

const MyComponent = () => {
  const [count, setCount] = useState(0);
  
  const destroy = () => {
      console.log('Component will unmount');
      // Perform cleanup tasks here, like unsubscribing or clearing timers
    };
  
  const create = () => {
    // Side effect code goes here, like subscribing to events or making network requests
    console.log('Component mounted');

    // Return a cleanup function
    return destroy;
  };

  useEffect(create, [count]); 
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

export default MyComponent;

For the example above:

  • Initialization:

    • Render phase -> Complete all three commit phases -> destroy() doesn’t execute as conditions aren’t met -> create() executes.

  • Update:

    • After clicking Increment -> Render phase -> Complete all three commit phases -> Execute destroy() -> Execute create().