Build your own React.js - Part 4. State Updates
Rinat Rezyapov
Posted on February 21, 2022
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
}
};
}
}
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;
}
}
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});
})
}
...
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);
}
...
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);
}
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();
}
It might seem weird to you that we call this intermediate
performUpdateIfNecessary
function that does nothing and then just call Component'sperformUpdateIfNecessary
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);
}
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);
}
}
...
-
First, we update
_currentElement
andprops
of the App class component instance to thenextElement
values.
this._currentElement = nextElement; this.props = nextElement.props;
In our case the
nextElement
will be just object:
{ props: { title: "React.js" }, type: App }
-
Then we assign
_pendingState
which is{ clickCount: 1 }
to the currentstate
of the App class component instance. And we clear_pendingState
after that by setting it tonull
.
this.state = this._pendingState; this._pendingState = null;
-
We assign
this._renderedComponent._currentElement
toprevRenderedElement
variable andthis.render()
tonextRenderedElement
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'srender
method before and after the user clicked and the event listener calledthis.setState({clickCount: this.state.clickCount + 1})
in the constructor of the App class component. -
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 nextdiv
element.
shouldUpdateComponent(prevRenderedElement, nextRenderedElement)
Let's create a file with the name
shouldUpdateComponent.js
and createshouldUpdateComponent
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. -
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 returntrue
andReconciler.receiveComponent
will get called with the following parameters:
// this._renderedComponent DOMComponentWrapper { _currentElement: { type: "div", props: { "children": "0" } }, _domNode: {} } // nextRenderedElement { type: "div", props: { children: 1 } }
-
Let's add
receiveComponent
to theReconciler
.
// 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 theApp
class component, butDOMComponentWrapper
. That's because DOM elements (div, span, etc) that need to be rendered are wrapped inDOMComponentWrapper
so that handling of these elements state (props, children) was easier and similar to handling class components state (see previous posts about DOMComponentWrapper). -
Now we need to go to
DOMComponentWrapper
and addreceiveComponent
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
forDOMComponentWrapper
looks a bit different fromComponent
's. I intentionally commented outthis._updateDOMProperties
because we are not interested in updating DOM properties for now and it will only complicate things. -
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 changingprevType !== nextType
. For example from number0
to stringno data
. We won't support it for now.Secondly, we check if
div
element has children at allnextType === 'undefined'
. If not, we skip.Then we check if the type of
children
of thediv
element is string or number. That's our case becausethis.state.clickCount
(which is child of thediv
) has the type ofnumber
.So we just grab the
nextProps.children
and insert it intodiv
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:
Posted on February 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 3, 2024
August 26, 2024