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:
What are side effects?
What stages does the commit phase include?
How does the asynchronous execution of
useEffect
work?And more...
Side Effects (Flags)
If we were to summarize the render phase, its core functions are:
Building the fiber tree
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:
beforeMutation
mutation
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. TheSnapshot
flag is only assigned toHostRoot
during initialization, so the root DOM node will have its content cleared.
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:
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 referencedref
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 thestateNode
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
andcommitHookEffectListMount
handle component content updates.commitHookEffectListUnmount
executes the destroy functions of updated components.commitHookEffectListMount
executes the mount functions of new components, such as theuseEffect
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.
Class Components
componentDidMount
is executed during initialization, andcomponentDidUpdate
during updates.If a callback is passed during
setState
, it is executed here, with access to the latest state.Function Components
During the layout phase, only hooks with the
Layout
tag are executed.useLayoutEffect
has aLayout
tag:mountEffectImpl(fiberFlags, Layout /*Tag*/, create, deps);
useEffect
has aPassive
tag:mountEffectImpl(Passive | PassiveStatic, Passive$1, create, deps);
The
commitHookEffectListMount
function calls theuseLayoutEffect
hook and stores its destroy function on the fiber. As all hooks form a linked list stored in the fiber, React distinguishes betweenuseLayoutEffect
anduseEffect
using a tag assigned during initialization: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 itsref
.
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()
-> Executecreate()
.