This is part 3/4 of a series on building your own React. I call the framework Minimum Viable React and the source code is available on Github. The other parts are:

Part 1: Simple Elements
Part 2: Components
Part 4: Lists and Keys

Build Your Own React - Part 3: State and Lifecycle

In this part, we'll add state, i.e. setState() method, to our framework. We can already build stateful applications by passing down props and calling MVRDom.render() each time the state changes. However, one of the main features of React is component state. Component state is a way of triggering updates in just one individual component and its children.

A typical use case for setState() is hiding and displaying certain elements of a component. Below is an app that displays some stats about four big metro systems. You can show and hide the logo for each metro system by clicking the buttons. Clicking the headers will sort the rows. All state transitions in the app are handled through setState(), running on Minimum Viable React as it stands at the end of this article.

You may have noticed that the application has a bug. The bug has to do with the lack of key attributes in the framework. Don't worry about that now, it's the topic for part 4.

State

This is, unfortunately, where things start to get a bit messy. In the past, we've had a clean tree structure where DOM nodes have pointers to virtual elements, and virtual-elements have pointers to components. Furthermore, components know nothing about the reconciler. This has to change because we have to give components the ability to notify the framework that their state has changed and that they should be re-rendered.

First, let's add the DOM element reference to components. We can do this in Reconciler.mountSimpleNode. Note that we add the reference to the parent component and all of its children.

1
2
3
4
5
6
7
8
9
10
11
mountSimpleNode: (virtualElement, container, oldDomElement, parentComponent) => {
...
// add reference to domElement into component
let component = virtualElement.component;
while (component) {
component.setDomElement(newDomElement);
component = component.getChild();
}
...

Here's a visualization of the references between Components, Virtual Elements, and DOM Elements.

In React, setState() is asynchronous, i.e. the component won't be re-rendered immediately. I think this is mostly a performance optimization enabling batching of updates. In MVR, we opt for simplicity by making everything synchronous.

Next, let's add a method called handleComponentStateChange() to Reconciler. It's pretty straight-forward: we'll update the state of the component, re-render it, and then start the normal diffing process. Only this time, diffing starts at the component whose state was changed, which is why we need a reference to the DOM element associated with the component.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
handleComponentStateChange(component, nextState) {
component.updateState(nextState);
const nextElement = component.render();
nextElement.component = component.getRoot();
// start the normal diffing process here
const domElement = component.getDomElement();
const container = domElement.parentNode;
const childComponent = component.getChild();
if (childComponent) {
Reconciler.diffComponent(
nextElement,
childComponent,
container,
domElement,
component
);
} else {
Reconciler.diff(nextElement, container, domElement, component);
}
}
}

The setState() method in the Component superclass looks like this:

1
2
3
4
5
setState(newState) {
const prevState = this.state;
const nextState = Object.assign({}, prevState || {}, newState);
this.onStateChange(this, nextState);
}

this.onStateChange() is the handleComponentStateChange() method from above. We just need a way to pass this function to Component as a callback. We can't do it in the constructor because that only takes one argument: props. We'll do the next best thing, which is to set it right after initialization in Reconciler.mountComponent().

1
2
3
mountComponent: (virtualElement, container, oldDomElement, parentComponent) => {
const component = new virtualElement.type(virtualElement.props);
component.setStateCallback(Reconciler.handleComponentStateChange); // new line

Lifecycle methods

Now that we have implemented the whole lifecycle of components (mounting, updating, unmounting), it's fairly trivial to add the lifecycle methods. All we have to do is add all of them to the Component superclass and them find the right place in the code to invoke each method. The methods are:

  • componentWillMount
  • componentDidMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate
  • componentDidUpdate
  • componentWillUnmount

I won't go through each one as, hopefully, it's quite easy to see how you'd call componentWillMount() in Reconciler.mountComponent and so on. Let's, however, take a look at how we would change the handleComponentStateChange() method above.

The biggest change is adding a conditional. Before each update, we have to call shouldComponentUpdate() with the next props and state. In this case, the props don't change so we just pass in the current props and the next state. We'll also have to add in the other lifecycle methods in a logical order: first componentWillUpdate(), and finally componentDidUpdate().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
handleComponentStateChange(component, nextState) {
const prevState = component.state;
if (component.shouldComponentUpdate(component.props, nextState)) {
component.componentWillUpdate(component.props, nextState);
// this part is the same as before
component.updateState(nextState);
const nextElement = component.render();
nextElement.component = component.getRoot();
const domElement = component.getDomElement();
const container = domElement.parentNode;
const childComponent = component.getChild();
if (childComponent) {
Reconciler.diffComponent(
nextElement,
childComponent,
container,
domElement,
component
);
} else {
Reconciler.diff(nextElement, container, domElement, component);
}
// finally we call componentDidUpdate
component.componentDidUpdate(component.props, prevState);
}
}

Conclusion

We're almost done! Adding setState() was a bit messy as we had to add complexity to the simple tree structure from the previous articles. Lifecycle methods, however, are almost trivial. The problem is merely to find the correct spot in the code for invoking them.

There's one important feature left: keys. Currently our only criterion for whether a component should be updated or replaced is whether they have the same constructor. This is not enough. We need each element to have some kind of an identity so that we can determine weather two elements should be considered the same instance of a component. We'll tackle this problem in part 4.