React Fiber & Concurrency. Part 1

JettyCloud
10 min readSep 25, 2023

--

My name is Dmitrii Glazkov, I’m a Software Developer at JettyCloud.

There are many articles and reports on the web that describe React Fiber. Unfortunately, a lot of them are no longer relevant. I noticed this and decided to update the information on the subject and create a reliable overview of React Fiber. I mainly relied on the source code and debugger, so in this article you will find many references to code from the React repository. Now, I want to share the results of my work with you.

The topic is divided into two articles. In this article, I will talk about the process of updating and making changes to the DOM. The second article is dedicated to the implementation of non-blocking rendering — Concurrent React (coming soon).

I want to note that the first article might not bring significant practical utility. However, the material described in it will help to understand the implementation of Concurrent React. This article will be particularly useful for those who want to delve into the workings of the tool from the inside, as well as extract interesting ideas for writing and structuring code.

React Fiber

React Fiber solves two main tasks:

  1. Incremental rendering process — the ability to divide rendering work into parts. In this aspect, the concept and implementation have largely remained the same, although there are some changes.
  2. Non-blocking rendering capability, where rendering should not block user interactions with the website and the display of animations. Here, everything is the opposite: the concept took a long time to develop and evolve. Details about how this task was solved will be disclosed in my other article. It’s important to note that the implementation of the first aspect — incremental rendering — serves as the foundation for implementing the second one.

Rendering, in this context, refers to the process of updating components and calculating changes between previous and current renders to subsequently apply changes to the DOM.

Next, we will delve into how the React team tackled the first task. In this article, we will examine the incremental rendering process, but we’ll cover it within the scope of the entire update process: updating components and making changes to the DOM.

Update Process

The entire update process is divided into 2 phases:

  1. The render phase, which is sometimes also referred to as reconciliation. In this phase, as mentioned before, React applies updates to components, compares the previous state of the application with the current one, and determines what update work needs to be done.
  2. The commit phase. Building upon the work is done in the previous phase, lifecycle methods and hooks are called, and the DOM is updated.

To illustrate these phases, I’ll use an example. I will use an application implementing a simple counter.


function CounterResult({ count }) {
return (
<span>{count}</span>
)
}

function Button({ onClick, children }) {
return (
<button onClick={onClick}>{children}</button>
)
}

function ClickCounter() {
const [count, setCount] = useState(0);

const handleClick = useCallback(() => {
setCount((value) => value + 1)
}, [])

return (
<div>
<Button onClick={handleClick}>Click me!</Button>
<CounterResult count={count} />
</div>
);
}

Reconciliation

We won’t discuss this phase from the very beginning. Instead, I suggest we first grasp some basic concepts of the rendering process, so that the narrative can be smoother and more coherent as we proceed.

React Elements

If we imagine our example without using JSX, it will look like this:

function CounterResult({  count }) {
return React.createElement("span", null, count);
}


function Button({ onClick, children }) {
return React.createElement("button", { onClick: onClick }, children);
}


function ClickCounter() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(value => value + 1);
}, []);
return React.createElement("div", null,
React.createElement(Button, { onClick: handleClick }, "Click me!"),
React.createElement(CounterResult, { count: count }));
}

As you can notice each component will create and return a React Element. As a result of React’s work on our application, we will obtain 6 React Elements: ClickCounter, div, Button, button, CounterResult, span. Each one is a regular object that looks something like this:

{
$$typeof: Symbol(react.element)
key: null
props: {}
ref: null
type: ClickCounter
}

Each React Element carries information about the current state of each element of the application.

Fiber Node

A Fiber Node is a fundamental element in the React Fiber architecture. A Fiber Node is created from a React Element using the createFiberFromElement function. This isn’t the only function that creates a Fiber Node, but it’s the one used in our case. However, the actual instance of the Fiber Node is created deeper in the createFiber function. Here’s how it looks:

function createFiber(tag: WorkTag, pendingProps: mixed, key: null | string, mod: TypeOfMode): Fiber {
return new FiberNode(tag, pendingProps, key, mode);
}

Let’s examine the structure of a Fiber Node. Here are its main fields that will help us later on:

export type Fiber = {

//type Fiber Node react/packages/shared/ReactWorkTags.js
tag: WorkTag,

//Function, class, or tag associated with this Fiber Node
type: any,

//DOM node
stateNode: any,

//Fields for constructing the Fiber Tree
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,

//Used to store information about various states and actions that need to be performed in the context of this node.
flags: Flags,
subtreeFlags: Flags,

//Reference to a Fiber Node in another tree. A pointer goes from the workInProgressTree to the currentTree and vice versa.
alternate: Fiber | null,
};

Fiber Tree

By using the return, child, and sibling fields, we can construct the Fiber Tree. The Fiber Tree reflects the structure and state of our entire application. In the case of our example, it will look as follows:

Side Note: Root

React creates a fiber root object for each tree. The fiber root holds a reference to the Fiber Tree. The Fiber Tree starts with a special type of Fiber Node called “Root” and acts as the parent for your initial component.

But we won’t have just one, we’ll have two such trees: currentTree and workInProgressTree. The currentTree reflects the current visible state of our application, while the workInProgressTree reflects the new state that needs to be applied. During the render phase, the workInProgressTree and its nodes will be created and modified. Nodes in the workInProgressTree are created based on nodes in the currentTree using the createWorkInProgress function. In this process, a Fiber Node for the workInProgressTree is created using the already familiar createFiber function.

Incremental Rendering

Now we can begin to understand how incremental rendering works. Its essence is as follows: we traverse each node in the workInProgressTree and perform some work on it. This way, rendering happens gradually, processing each node and having the ability to interrupt work before processing the next node.

It all starts with creating a Root node for the workInProgressTree based on the Root from the currentTree. Here, we thank ourselves for the preparatory work we’ve done, as we already know that creating the Root node is done using the createWorkInProgress function. In the case of the initial rendering of our application, the Root node in the currentTree will be completely empty.

Next, we initiate a loop to traverse the nodes in the workInProgressTree. At this point, we only have one such node — the Root — and we start with it.

function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}

In this loop, we invoke the performUnitOfWork function until we have workInProgress nodes that need to be processed.

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

const next = beginWork(current, unitOfWork, renderLanes);

unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}

The performUnitOfWork function carries out all the work on the Fiber Node associated with updating components and calculating changes between the previous render and the current one, for subsequent changes in the DOM. Executing this function can be divided into two stages: beginWork and completeUnitOfWork.

We will delve into these stages in detail later, but for now, I want to discuss what they have in common. Both functions fill the flags and subtreeFlags fields that exists in the Fiber Node. These flags and subtreeFlags determine various states and actions that need to be performed in the context of that node. All possible values for these fields are stored in the file. Examples include:

  • Update: Indicates that the Fiber Node requires an update.
  • Snapshot: Indicates that the getSnapshotBeforeUpdate hook needs to be called.

It’s worth noting that these fields store binary values. So, Update is represented as 100, and Snapshot is represented as 10000000000.

Sidenote: You may have heard of “effects” and the “effects list,” but I assume this theory is outdated, as I couldn’t find any evidence of the effects list being formed during the render phase. Yes, Fiber has fields like nextEffect, firstEffect, and lastEffect, but they are not filled or used in the render phase, only in the commit phase. Thus, the assertion that an effects list is formed during the render phase, which is then traversed in the commit phase to perform actions on nodes, is not accurate.

beginWork

The beginWork function performs the call of a component and creates Fiber Nodes for its child nodes. By “call of a component,” I refer to the process of executing the component’s body. In class components, this process begins with calling the render() method. In functional components, it involves executing the component function itself. In our case, the call will be done for the ClickCounter, Button, and CounterResult nodes using the renderWithHooks function. The output gives us child React Elements. Then, Fiber Nodes are created for these child elements. Fiber Nodes are created based on React Elements.

We created React Elements at the moment of calling the component using the React.createElement function. I intentionally provided an example of our application without JSX to emphasize that React Elements are created within the return statement.

If we are creating a Fiber Node for the first time (meaning there’s no corresponding node in the currentTree for the current Fiber Node), then the creation happens using the createFiberFromElement function. However, if we are updating a Fiber Node (meaning there’s a corresponding node in the currentTree), then a node is created using the createWorkInProgress function, based on the node from the currentTree and the newly created React Element.

The process can be summarized as follows:

  1. The renderWithHooks function invokes the component, leading to the creation of child React Elements.
  2. Child nodes of the current Fiber Node are created based on the React Elements and nodes from the currentTree.

These two stages ensure the sequential construction of the workInProgressTree.

Additionally, during the execution process, we set values in the flags field.

Using bitwise OR (`flags |= Snapshot`), we can store multiple values in the flags field for a given node.

By the end of the beginWork process, our tree will look like this:

Value 1 represents PerformedWork — work has been performed on the node. This flag is set for class or functional components.

completeUnitOfWork

The completeUnitOfWork function is called not always, but only when next === null. Here, next refers to the next child node. Thus, the condition next === null tells us that the current node doesn’t have any child element that needs to be processed. In this case, the completeUnitOfWork function is triggered. This function performs the bubbling process up to the nearest node that has an unchecked sibling node. During this bubbling, we set the value for the subtreeFlags field as follows:

subtreeFlags |= child.subtreeFlags;
subtreeFlags |= child.flags;

This field contains flags for all of its descendants. Thanks to this, by looking at the parent node, we can understand the states and changes stored by its descendants.

Additionally, while executing completeUnitOfWork, the changes that need to be made to the node are determined. The modified DOM element is placed in the stateNode field, and the node is marked with an Update flag.

Next, I’ll provide an example of a tree obtained during the completeUnitOfWork process. However, this example will describe a situation where a button was pressed and the counter value changed from 0 to 1.

The first time the bubbling starts from the button node, as it doesn’t have any child nodes that need processing. We bubble up to the Button node, which has an unchecked sibling node — CounterResult. The span node has flags = 100, which represents an Update. Thus, at the end of traversing the entire tree, the Root node knows that among its descendants, there are nodes with PerformedWork and Update flags.

With this, we conclude the explanation of the render phase and move on to the commit phase.

Commit phase

In this phase, we are already working with the finishedWork tree, which is the workInProgressTree from the render phase:

const finishedWork = root.current.alternate;
root.finishedWork = finishedWork;

During the commit stage, using the constructed tree and relying on the flags and subtreeFlags fields, we execute lifecycle methods, hooks, and update the DOM. The commit phase begins with the commitRoot function, but the main logic is in commitRootImpl. In this function, we’ll discuss four main sub-functions that are called in the following order:

Now, let’s understand how the flags and subtreeFlags fields help us. Suppose we have a value of 10000000000 in subtreeFlags — this is the Snapshot flag. Before executing commitBeforeMutationEffects, we can check the subtreeFlags of the Root node to determine if there are descendants that need to have getSnapshotBeforeUpdate performed on them. This check is done using bitwise AND: subtreeFlags & Snapshot. Thus, we can progressively traverse the tree and eventually find the node we need.

The same applies to our example. When executing commitMutationEffects, we need to make changes to the DOM. How do we find the nodes that need to be updated? We check the condition subtreeFlags & Update to realize if there are descendants that need to be updated. In this way, we traverse the entire tree and reach our span node.

Conclusion

We’ve discussed how incremental rendering works, helping implement concurrency by breaking rendering into parts. This allows us to perform rendering not all at once but in pieces, continually checking for urgent updates that need rendering and allowing the browser to render without blocking it. For more details, read the next part.

--

--

JettyCloud

Georgian IT company. We participate in the development of an American UCaaS platform RingCentral