DEV Community

Cover image for Build your own React.js - Part 4. State Updates
Rinat Rezyapov
Rinat Rezyapov

Posted on • Edited on

Build your own React.js - Part 4. State Updates

Table Of Content

Introduction

In the previous articles, we implemented the mounting process of the class component and its children to the DOM. Although mounting into the DOM is the crucial step of the rendering process in React.js, it's the updating of the DOM where React.js really shines. As you may know, React.js do it by keeping "virtual" DOM in memory and syncing it with the real DOM, thus making DOM manipulations faster.

There are many ways to trigger an update process in React.js. It could be user interaction, some event triggered by setInterval or notification from a web socket. We will use a user interaction because it's the most common.

We know that React.js has setState API which updates state object and, by default, triggers re-rendering. setState can be launched in different parts of the application (except render() method of a class component), but for now, we will focus on updating state in response to user interaction with our application. For example, a user clicked a button, which triggered onClick event handler, which in turn updated the local state of the class component by calling setState.

Let's implement this flow but with one restriction, instead of adding support for event handlers to DOM nodes, e.g. onClick attribute of a button, we will use the click event listener and update the local state of a class component each time user clicks somewhere in the window of a browser. The reason for this restriction is that supporting event handling in React.js is a topic for another conversation. Maybe we will return to this subject later.

Adding state to class component

For now, let's change the App class component for our future local state implementation.

We will start by adding the constructor method to the App class component. Inside the constructor, we first call super method. This is an important step because overwise the state initialization won't work. If you want to know more about super Dan Abramov wrote a whole article about it.
Secondly, we initialize clickCount field with the value 0 inside state object of the App class component. We will also change the content of the render method with this.state.clickCount value rendering inside div element.

  // index.js

 class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      clickCount: 0,
    }
  }

  render() {
    return {
      type: "div",
      props: {
        children: this.state.clickCount
      }
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Here I want to go back in time a little bit for those who follow Build your own React.js series from the beginning. Those who are new can skip this part.

Since we now render value with the type of number in the div element, we need to teach our DOMComponentWrapper to render numbers. We will do it by adding typeof props.children === "number" in the condition.

   // DOMComponentWrapper.js
  _createInitialDOMChildren(props) {
    if (
      typeof props.children === "string" || 
      typeof props.children === "number"
    ) {
      this._domNode.textContent = props.children;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Ok, let's return back to present times. Sorry for that little adventure in time.

Now we need to call setState every time a user clicks the left button of the mouse. For that, we need to add an event listener (remember we agreed that we won't add support for event handling?). Usually, we add an event listener in componentDidMount component's lifecycle, but since we don't have lifecycles yet, we are going to add it in the constructor of a class component.

I skipped clearing an event listener on the class component unmount intentionally (don't do it in a real project!).

  // index.js

 class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      clickCount: 0,
    }
    window.addEventListener('click', () => {
      this.setState({clickCount: this.state.clickCount + 1});
    })
  }
 ...

Enter fullscreen mode Exit fullscreen mode

Let's now add setState method to the Component class so that the App class component can inherit it.

class Component {
  constructor() {
    ...
    this._pendingState = null;
    ...
  }
  setState(partialState) {
    this._pendingState = partialState;
    UpdateQueue.enqueueSetState(this, partialState);
  }
  ...
Enter fullscreen mode Exit fullscreen mode

Method setState takes partialState as an argument. It's called partialState because setState doesn't require you to provide a full updated state object as an argument, it only needs part of the state that you want to update, so it can merge it into the current state object.

We assign partialState to this._pendingState in the constructor and then call UpdateQueue.enqueueSetState(this, partialState) with an instance of the App class component and partialState as an arguments.

Let's create UpdateQueue.js with enqueueSetState function.

// UpdateQueue.js
import Reconciler from "./Reconciler";

function enqueueSetState(instance, partialState) {
  instance._pendingState = Object.assign(
    {}, 
    instance.state, 
    partialState
  );
  Reconciler.performUpdateIfNecessary(instance);
}

Enter fullscreen mode Exit fullscreen mode

Nothing special here, we just take partialState and merge it with the state object of the instance using Object.assign. Empty object as a first argument is just making sure that we create a new object every time.

In the real React.js library enqueueSetState also queueing multiple partialStates so that at the right time it could do batch update.

After that, we pass control to Reconciler.performUpdateIfNecessary(instance) which in turn passes control back to the method performUpdateIfNecessary of the instance of the App class component which in turn inherited from Component class.

// Reconciler.js

function performUpdateIfNecessary(component) {
  component.performUpdateIfNecessary();
}
Enter fullscreen mode Exit fullscreen mode

It might seem weird to you that we call this intermediate performUpdateIfNecessary function that does nothing and then just call Component's performUpdateIfNecessary from it. Remember, in the real React.js there is more complicated logic inside these functions (such as batch state update) we just skip this logic and try to preserve the order of function calls.

In the Component class, we create performUpdateIfNecessary method and call Component's updateComponent method from it.

// Component.js

performUpdateIfNecessary() {
    this.updateComponent(this._currentElement);
}
Enter fullscreen mode Exit fullscreen mode

Update Component

Now, let's look at the updateComponent method. It's a big one, so let's go through it step by step.

  updateComponent(nextElement) {
    this._currentElement = nextElement; // 1
    this.props = nextElement.props;
    this.state = this._pendingState; // 2
    this._pendingState = null;

    let prevRenderedElement = this._renderedComponent._currentElement;
    let nextRenderedElement = this.render(); // 3

    if (shouldUpdateComponent(prevRenderedElement, nextRenderedElement)) { // 4
      Reconciler.receiveComponent(this._renderedComponent, nextRenderedElement);
    }
  }
...
Enter fullscreen mode Exit fullscreen mode
  1. First, we update _currentElement and props of the App class component instance to the nextElement values.

    this._currentElement = nextElement; 
    this.props = nextElement.props;
    

    In our case the nextElement will be just object:

    {
      props: {
        title: "React.js"
      },
      type: App
    }
    
  2. Then we assign _pendingState which is { clickCount: 1 } to the current state of the App class component instance. And we clear _pendingState after that by setting it to null.

    this.state = this._pendingState;
    this._pendingState = null;
    
  3. We assign this._renderedComponent._currentElement to prevRenderedElement variable and this.render() to nextRenderedElement variable.

    let prevRenderedElement = this._renderedComponent._currentElement;
    let nextRenderedElement = this.render();
    

    The values of these variables, in our case, are following:

    // prevRenderedElement 
    {
        "type": "div",
        "props": {
            "children": 0 // this.state.clickCount
        }
    }
    // nextRenderedElement 
    {
        "type": "div",
        "props": {
            "children": 1 // this.state.clickCount
        }
    }
    

    As you can see it's just the state of the div element in the App class component's render method before and after the user clicked and the event listener called this.setState({clickCount: this.state.clickCount + 1}) in the constructor of the App class component.

  4. With these preparations, we are ready to decide whether should we update the component or just re-mount it. We call shouldUpdateComponent with the previous and the next div element.

    shouldUpdateComponent(prevRenderedElement, nextRenderedElement)
    

    Let's create a file with the name shouldUpdateComponent.js and create shouldUpdateComponent function inside:

    // shouldUpdateComponent.js
    
    function shouldUpdateComponent(prevElement, nextElement) {
      // this needs only for primitives (strings, numbers, ...)
      let prevType = typeof prevElement;
      let nextType = typeof nextElement;
    
      if (prevType === 'string') {
        return nextType === 'string';
      }
    
      return prevElement.type === nextElement.type;
    }
    
    

    Here you can see one of the two assumptions that React.js makes when comparing two trees of elements.

    Two elements of different types will produce different trees.

    In our case, the element div doesn't change its type so we can reuse the instance and just update it.

  5. Let's return to updateComponent method of the Component class.

    if (
         shouldUpdateComponent(
           prevRenderedElement, 
           nextRenderedElement
         )
        ) {
          Reconciler.receiveComponent(
            this._renderedComponent, 
            nextRenderedElement
          );
        }
        ...
    

    We know that, in our case, shouldUpdateComponent will return true and Reconciler.receiveComponent will get called with the following parameters:

    // this._renderedComponent
    DOMComponentWrapper {
        _currentElement: {
            type: "div",
            props: {
                "children": "0"
            }
         },
        _domNode: {}
     }
    
    // nextRenderedElement
    {
        type: "div",
        props: {
            children: 1
        }
    }
    
  6. Let's add receiveComponent to the Reconciler.

    // Reconciler.js
    
    function receiveComponent(component, element) {
      component.receiveComponent(element);
    }
    

    Again, this is the place where more optimizations happen in the real React.js, for now, we won't focus on that.

    The important part here is that the component argument of the function is not the App class component, but DOMComponentWrapper. That's because DOM elements (div, span, etc) that need to be rendered are wrapped in DOMComponentWrapper so that handling of these elements state (props, children) was easier and similar to handling class components state (see previous posts about DOMComponentWrapper).

  7. Now we need to go to DOMComponentWrapper and add receiveComponent method.

      receiveComponent(nextElement) {
        this.updateComponent(this._currentElement, nextElement);
      }
    
      updateComponent(prevElement, nextElement) {
        this._currentElement = nextElement;
        // this._updateDOMProperties(prevElement.props, nextElement.props);
        this._updateDOMChildren(prevElement.props, nextElement.props);
      }
    

    As you can see updateComponent for DOMComponentWrapper looks a bit different from Component's. I intentionally commented out this._updateDOMProperties because we are not interested in updating DOM properties for now and it will only complicate things.

  8. So let's jump into this._updateDOMChildren:

      _updateDOMChildren(prevProps, nextProps) {
        let prevType = typeof prevProps.children;
        let nextType = typeof nextProps.children;
        if (prevType !== nextType) {
          throw new Error('switching between different children is not supported');
         }
    
        // Childless node, skip
        if (nextType === 'undefined') {
          return;
        }
    
         if (nextType === 'string' || nextType === 'number') {
          this._domNode.textContent = nextProps.children;
         }
       }
    

    First, we throw an error if, in our case, the type of children of our div element is changing prevType !== nextType. For example from number 0 to string no data. We won't support it for now.

    Secondly, we check if div element has children at all nextType === 'undefined'. If not, we skip.

    Then we check if the type of children of the div element is string or number. That's our case because this.state.clickCount (which is child of the div) has the type of number.

    So we just grab the nextProps.children and insert it into div text content.

Let's stop here because we already covered too much. At this point, you'll be able to open our app and see the number incrementing at each click. That means our custom written React.js library can handle the state.

Congratulation!

In the next posts, we will continue to improve the state handling in our library.

Links:

  1. Github repo with the source code from this article
  2. Codesandbox with the code from this article (refresh Codesandbox page if you don't see results)
  3. Building React From Scratch talk
  4. React.js docs regarding Building React From Scratch talk

Top comments (0)