A deep dive into React Fiber Internals – LogRocket Blog

Ever wonder what happens when you call reactdom.render (
, document.getelementById (‘root’))?

We know that ReactDom builds the DOM tree in the background and renders the application to the screen. But how does ReactDom build a DOm tree? How does app update the tree when its state changes?

In this article, I start with a look at how React built DOM trees before 15.0.0, the pitfalls of this model, and how to address them in the new 16.0.0 model. This article will cover many concepts of internal implementation details that are not necessary for actual development using React.

Stack reconciler

Let’s start with the familiar reactdom.render (
, document.getelementByid (‘root’)).

The ReactDOM module passes the
to the Reconciler, and there are two problems:

  1. <App />What does it mean?
  2. What is the reconciler

Let’s untangle these two issues:
is a React element, which is also lements describe the tree.

An element is a generic object that describes an instance or DOM node of a component and its desired properties. –React Blog

In other words, an element is not a real DOM node or component instance. They’re just a way of telling React what element types they are, what properties they have, and what child nodes they have.

This is the real power of React. React simplifies the life of developers by removing the complexity of how to build, render, and manage the life cycle of a real DOM tree. To see what they really mean, let’s look at the concept of using object orientation.

In a typical object-oriented world, developers need to instantiate and manage the life cycle of each DOM element. For example, if you want to create a simple form and a submit button, state management and even simple operations require developer effort.

Assuming that the Button component has a state variable isSubmitted, the life cycle of the Button is similar to the following flow chart, where each state needs to be processed by APP:

The size of the flowchart and the number of lines of code increase exponentially as the state variables increase.

React has elements that solve this problem precisely. In React, there are two types of elements:

  • DOM element: when the element type is a string, for example:<button class="okButton"> OK </button>
  • Component element: when the type is a class or function, for example<Button className="okButton"> OK </Button>Here,ButtonThe React component is a class component or function component. This is the typical React component we use.

It is important to understand that two classes or functions are a simple object. They simply describe what needs to be rendered on the screen and do not cause any rendering to happen when you create or instantiate. This is very easy for React to parse and iterate over them to build a DOM tree. The actual rendering takes place when the traversal is complete.

When React encounters a class or function component, it determines which element it needs to render based on the component’s props. For example, if
is as follows:

<Form>
  <Button>
    Submit
  </Button>
</Form>
Copy the code

React determines the

and

const Form = (props) = > {
  return(
    <div className="form">
      {props.form}
    </div>)}Copy the code

React calls Render () to determine which element it renders, and eventually sees it render a

with a child. React will repeat this process until it determines the base DOM tag element for each component on the page.

The process of recursively traversing a tree to determine the underlying DOM tag elements of the React application component tree is called reconciliation. After reconciliation, React knows the results of the DOM tree and the minimal set of changes required for renderers like React-DOM or React-Native to update DOM nodes.

This means that when you call reactdom.render () or setState, React will perform a reconciliation. In the case of setState, it performs a traverse and compares the new tree to the old tree to find the change. It then applies these changes to the current tree, updating it to the state corresponding to the call to setState.

Now that we understand what reconciliation is, let’s look at the pitfalls of this model.

By the way, why is it called the “Stack” Reconciler?

The name comes from the Stack data structure, which is a last-in, first-out mechanism. Does stack have anything to do with what we just saw? Well, it turns out, since we’re actually recursing, it has to do with stack.

Recursion

To understand why this happens, let’s take a quick example of what happens to the Call stack

function fib(n) {
  if (n < 2) {return n
  }
  return fib(n - 1) + fib (n - 2)
}

fib(10)
Copy the code

As we can see, the call stack pushes each call to fib() until the first function to be returned pops up and calls fib(1). It then continues the push recursive call and pops up when the return statement is reached. This way it effectively uses the stack until FIB (3) returns the last element to be the last to pop from the stack.

The reconciliation algorithm we just saw is purely recursive. Updates to subtrees are immediately rerendered. While this works well, there are some limitations. As Andrew Clark put it:

  • In a UI, it is not necessary to apply every update immediately; in fact, doing so is wasteful and can cause frames to drop, reducing the user experience.
  • Different types of updates have different priorities – an animation update needs to be completed faster than a data update

Now, what do we mean by dropping frames? Why is the recursive method problematic? To grasp this, let me briefly explain what frame rate is and why it’s important from a user experience perspective.

Frame rate refers to the frequency at which consecutive images are displayed on the screen. Everything we see on a computer screen is made up of images or frames that are played on the screen at a rate that is instantaneous.

To understand it, think of a computer monitor as turning a book, viewing each page as a frame at a certain rate as you turn the page. In other words, a computer screen is just a book that turns pages automatically, playing changes all the time. If you still don’t understand, please see the video below (this is a video of XX, replaced by pictures below).

Typically, videos need to be played at 30 frames per second (FPS) to feel smooth to the human eye. A higher rate gives a better experience. This is the main reason why gamers prefer higher framerates in first-person precision shooters.

Having said that, most devices today refresh the screen at 60 frames, or to put it another way, 1/60=16.67ms, which means a new frame is displayed every 16ms. This number is important because React renders more than 16ms on the screen and the browser drops frames.

In fact, the browser still has some basic work to do, so your operation must be completed within 10ms. If you can’t meet this requirement, the frames will drop and everything on the screen will look messy. This has a negative impact on the user experience.

Of course, this is not a big reason for static and textual content to be noticed. But in the case of animation, this number is critical. So if the React Reconciliation algorithm iterates through the App tree each time it updates, it rerenders. If traversal is longer than 16ms, frames will be dropped.

This is an important reason why it is better to sort updates by priority rather than blindly apply every update that is passed to reconciliation. Another nice feature is to pause and resume on the next frame. In this way React can use a 16ms budget for better rendering control.

This led the React team to rewrite the Reconciliation algorithm, which is called Fiber. I hope that NOW I have a better understanding of why Fiber exists and its significance. Let’s take a look at how Fiber solves this problem.

How Fiber works

Now that we know what motivated you to develop Fiber, let’s summarize what it needs to do.

Again, I quote Andrew Clark’s notes:

  • Assign different priorities to different types
  • Pause and resume it later
  • You can terminate if you don’t need to
  • Reuse previously done work

One of the challenges of doing something like this is the way JavaScript engines work, and there is a certain lack of such threads in JS. To understand this, let’s explore how the JS engine handles the execution context.

JavaScript execution stack

Every time you write a function in JS, the JS engine creates the execution context when we call the function. Each time the JS engine starts, it creates a global context that contains global objects, such as the Window object in the browser, and the Global object in NodeJS. In JS, these contexts are handled using stack data structures.

So, when you write the following code:

function a() {
  console.log("i am a")
  b()
}

function b() {
  console.log("i am b")
}

a()
Copy the code

The Js engine first creates a global context and pushes it into the execution stack. It then creates the function execution context for function A (), and since b() is called from a(), it creates the function execution context for b() and pushes it onto the stack.

When function B () returns, the engine destroys the context of b(), and when we exit from a(), the context of A () is destroyed. The stack during execution looks like this:

But what happens when the browser has an asynchronous event such as an HTTP request? Does the JS engine stack asynchronous events and wait for them to complete?

The JS engine does something different here. At the top of the stack, the JS engine also has a queue of data structures called event queues. Event queues handle calls to asynchronous events, such as HTTP or network events in a browser.

The Js engine handles queues by leaving the stack empty. So every time the stack gets empty, the JS engine looks at the event queue, pops out the item, and executes it. It is important to note that the JS engine checks the event queue only when the execution stack is empty or only the global execution context is in the execution stack.

Although we call them asynchronous events, there is a subtle difference: events are asynchronous relative to when they are queued, but not truly asynchronous relative to when they are actually processed.

Back in the Stack Reconciler, the React traversal tree is done in the execution stack. So when the computational updates are complete, they are put in the event queue. Updates are executed only if the stack is empty. This is exactly the problem Fiber solves, almost realizing the intelligent function of stopping, resuming, and stopping the stack.

To quote Andrew Clark again:

Fiber is reimplementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.

The advantage of reimplementing the stack is that you can keep stack frames in memory and execute them however (and whenever) you want. This is crucial for accomplishing the goals we have for scheduling.

Aside from scheduling, manually dealing with stack frames unlocks the potential for features such as concurrency and error boundaries. We will cover these topics in future sections.

Simply put, Fiber is a unit of work with its own virtual stack. In the reconciliation hair implementation earlier, React creates an immutable tree object and iterates through it recursively.

In the current implementation, React creates a tree of modifiable Fiber nodes that describe the component’s state, props, and underlying DOM elements to render.

And since fiber nodes can be changed, React doesn’t need to create every node for updates. It can simply clone and update nodes when there are updates. Also, if it’s a Fiber tree, React doesn’t do recursive traversal. Instead, it creates a single linked list for father-first, depth-first traversal.

Singly linked list of fiber nodes

The Fiber node represents a stack frame and an example of the React component. A Fiber node contains the following properties:

Type

Div, SPAN, etc., such as host Components or composite components of class and funciton

Key

Same as the key passed to the React component

Child

When we call render on the component, we return elements such as:

const Name = (props) = > {
  return(
    <div className="name">
      {props.name}
    </div>)}Copy the code

Here the child of Name is div because it returns a DIV element

Sibling

Represents the list of elements returned by Render

const Name = (props) = > {
  return([<Customdiv1 />.<Customdiv2 />])}Copy the code

In the example above,
and
are the children of

. The Subling of
is
and they form a single linked list.

Return

Represents the frame in the stack that is returned. Logically, the parent fiber node is returned, so it represents the parent. For example, in the above example, Customdiv1’s return points to Name

pendingProps and memoizedProps

Memoization means storing the results of a function’s execution for later use, thus avoiding double computation. PendingProps represents the props passed to the component, and memoizedProps is initialized at the end of the execution stack and stores the props of that node.

When the incoming pendingProps and memoizedProps are equal, the output representing fiber before can be reused, avoiding unnecessary work.

pendingWorkPriority

The number representing the work priority represented by Fiber, and the ReactPriorityLevel module lists the different priorities and what they mean. A higher number indicates a lower priority, except that NoWork is zero.

For example, you can use the following function to check if fiber’s priority is at least as high as a given level. The scheduler uses the priority field to search for the next unit of work to execute.

function matchesPriority(fiber, priority) {
  returnfiber.pendingWorkPriority ! = =0 &&
         fiber.pendingWorkPriority <= priority
}
Copy the code

Alternate

At any time, an instance of a component corresponds to at most two fibers: current fiber and in-progress fiber. The alternate of the current node is fiber in progress, and the alternate of fiber in progress corresponds to the current fiber. The current fiber represents rendered, and the ongoing fiber represents frames in the stack that have not yet been returned.

Output

React leaf nodes. They are normal tags in the rendering environment (for example, in browsing, they are div, SPAN, etc.), and in JSX, they are represented by lower-case tag names.

Conceptually, fiber’s output is the return value of the function. Each fiber eventually has an output, but the output only creates leaf nodes from the base component. Then mount output to the tree.

The value of output is supplied to the renderer so that changes can be flushed. For example, let’s look at the following code fiber tree:

const Parent1 = (props) = > {
  return([<Child11 />.<Child12 />])}const Parent2 = (props) = > {
  return(<Child21 />)}class App extends Component {
  constructor(props) {
    super(props)
  }
  render() {
    <div>
      <Parent1 />
      <Parent2 />
    </div>
  }
}

ReactDOM.render(<App />.document.getElementById('root'))
Copy the code

We can see that the Fiber tree is composed of singly linked lists of child nodes and parent-child relationships. This tree can be traversed using depth-first search.

Render phase

To understand how React builds the tree and executes the Reconciliation algorithm on it, I decided to write a unit test in the React source code to debugger the process.

If you’re interested in this process, clone the React source code, go to this directory, and add jest tests to debbuger. The test I wrote was very simple, very basic rendering: a button with text. When you click the button, the app will destroy the button and render a div with a different text, where text is a state variable.

'use strict';

let React;
let ReactDOM;

describe('ReactUnderstanding'.() = > {
  beforeEach(() = > {
    React = require('react');
    ReactDOM = require('react-dom');
  });

  it('works'.() = > {
    let instance;
  
    class App extends React.Component {
      constructor(props) {
        super(props)
        this.state = {
          text: "hello"
        }
      }

      handleClick = () = > {
        this.props.logger('before-setState'.this.state.text);
        this.setState({ text: "hi" })
        this.props.logger('after-setState'.this.state.text);
      }

      render() {
        instance = this;
        this.props.logger('render'.this.state.text);
        if(this.state.text === "hello") {
        return (
          <div>
            <div>
              <button onClick={this.handleClick.bind(this)}>
                {this.state.text}
              </button>
            </div>
          </div>
        )} else {
          return (
            <div>
              hello
            </div>)}}}const container = document.createElement('div');
    const logger = jest.fn();
    ReactDOM.render(<App logger={logger}/>, container);
    console.log("clicking");
    instance.handleClick();
    console.log("clicked");

    expect(container.innerHTML).toBe(
      '<div>hello</div>'
    )

    expect(logger.mock.calls).toEqual(
      [["render"."hello"],
      ["before-setState"."hello"],
      ["render"."hi"],
      ["after-setState"."hi"]]); })});Copy the code

In the initial render, React creates a current tree, which is the original tree to render. CreateFiberFromTypeAndProps () is a according to the data elements React to create each React fiber function. When we run the test, we add a breakpoint to the function and see the call stack, which looks something like this:

As we see, the call stack from the render () calls, finally to createFiberFromTypeAndProps (). There are a few more functions that we are very interested in: workLoopSync(), performUnitOfWork(), beginWork().

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  while(workInProgress ! = =null) { workInProgress = performUnitOfWork(workInProgress); }}Copy the code

In workLoopSync(), React builds the tree from the

node and moves recursively to the child nodes

,

,

PerformUnitOfWork () takes the fiber node as an input parameter to get the alternate node and calls beginWork(), which is equivalent to starting a function context in the execution stack.

When the React to build tree, beginWork () is simply to use createFiberFromTypeAndProps () to create fiber node. React executes recursively, and eventually performUnitOfWork() returns null, indicating the end of the tree.

But what happens if we do instance.handleclick () (clicking on a button triggers a state update)? In this case, React iterates through the Fiber tree, clones each node, and checks if each node needs to be updated when we look at the call stack for this scenario, it looks like this:

Although we don’t see completeUnitOfWork() and completeWork() in the first call stack, we do in this figure. Just like performUnitOfWork() and beginWork(), these two functions perform the completion of the current run, which essentially means that it has been returned to the stack. These two functions perform the completion part of the current execution which effectively means returning to the Stack.)

As we can see, these four functions work together and also control the work that is currently being done, which is what the Stack Reconciler lacks. As you can see from the figure below, each Fiber node consists of four phases:

It is important to remember that each node does not execute completeUnitOfWork() until the child and sibling nodes execute completeWork() and return. For example, for

, it starts with performUnitOfWork() and beginWork(), then continues with Parant1’s performUnitOfWork() and beginWork() and so on… Once all the children of
have completed completeWork(), execution will return to
.

That’s the render phase of React. The tree built based on the Click () update is called the workInProgress tree, which is a draft tree waiting to be rendered.

Commit phase

Once the render phase is complete, React is ready for the commit phase. At this stage, it swaps the root Pointers of the current tree and the workInProgress tree to complete the replacement of the current tree and the draft tree based on the click() update.

React also reuses previous nodes. This optimization results in a smooth transition from the previous state to the next.

What about 16 frames? React has an internal counter for each running unit of work and constantly listens for time limits as it executes. When the time is up, React pauses the currently executing unit of work and gives control to the main thread for the browser to render.

Then, on the next frame, React resumes building the tree from where it paused. When it has enough time, it submits the workInProgress tree to complete the rendering.

conclusion

For that, I highly recommend you watch this video by Lin Clark, where she explains the algorithm with a nice animation that makes it easier to understand. (This is a video of XX, replaced by pictures below)

Hope you enjoyed reading this article.

Recommendation:

  • React -custom-renderer-1
  • inside fiber in depth overview of the new reconciliation algorithm in react
  • React Components, Elements, and Instances