This is part 2/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 3: State and Lifecycle
Part 4: Lists and Keys

Build Your Own React - Part 2: Components

In this article, we pick up where we left off in part 1. Our first app isn't particularly impressive, but it works well and has some features crucial to a practical React implementation. For example, it supports event handlers and updates only the parts of the DOM that change.

Let's face it, React wouldn't be a popular framework if it weren't for its composable component system. That's what we'll implement in this edition of the Minimum Viable React.

Let's start by looking at how we use components in React. Here's a simple example. First, we create a new component by extending React.Component class.

1
2
3
4
5
class MyComponent extends React.Component {
render() {
return <div>This is a custom comopnent.</div>
}
}

Next, we call createElement() with that class.

1
2
3
4
ReactDom.render(
MVR.createElement(MyComponent),
container
);

The first obvious thing we have to do is create an MVR.Component that we can extend. Let's do that in MVR.js. Here's the code we'll add at this point. We'll add other methods to this in the future, but for now, we'll only need constructor() and render(), which is always overridden.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Component {
constructor(props) {
this.props = props;
}
render() {}
}
export default {
createElement,
Component
};

As you can see, its constructor takes the props as an argument and assigns them to this.props, which is why, when you override the constructor, you have to call super(props).

The Big Picture

The difference between components and simple virtual elements is that components don't directly represent a real DOM element. Instead, they are factories that create more simple virtual elements that will eventually be used to create DOM elements. We need a way to keep track of which component is responsible for creating which simple element.

Because our tree of virtual elements no longer matches the DOM exactly, we'll have to update the diagram from part 1.

VDOM diagram with components

Now each virtual element stores a reference to the component that created it. This way, as we're diffing the DOM, we can re-render components with new props to create the new virtual elements, which we'll go on and diff with the real DOM. If this sounds confusing, some code will hopefully make it easier to wrap your head around.

MVRDom.js

Let's look at MVRDom.js and Reconciler.diff(). The new code in this function is on lines 3-7. On line 5, we test if the type of the element.type is a function. Remember that, when rendering a component, the first argument to MVR.createElement is the component class. The type of a class in Javascript is function. Therefore, we can detect a virtual element that represents a component by checking if its type is function. If the check returns true, we invoke a new method: Reconciler.diffComponent().

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
diff: (virtualElement, container, oldDomElement, parentComponent) => {
const oldVirtualElement = oldDomElement && oldDomElement._virtualElement;
const oldComponent = oldVirtualElement && oldVirtualElement.component;
if (typeof virtualElement.type === 'function') {
Reconciler.diffComponent(virtualElement, oldComponent, container, oldDomElement, parentComponent);
} else if (oldVirtualElement && oldVirtualElement.type === virtualElement.type) {
if (oldVirtualElement.type === 'text') {
Reconciler.updateTextNode(oldDomElement, virtualElement, oldVirtualElement);
} else {
Reconciler.updateDomElement(oldDomElement, virtualElement, oldVirtualElement);
}
// save the virtualElement on the domElement
// so that we can retrieve it next time
oldDomElement._virtualElement = virtualElement;
virtualElement.children.forEach((childElement, i) => {
Reconciler.diff(childElement, oldDomElement, oldDomElement.childNodes[i]);
});
// remove extra children
const oldChildren = oldDomElement.childNodes;
if (oldChildren.length > virtualElement.children.length) {
for (let i = oldChildren.length - 1; i >= virtualElement.children.length; i -= 1) {
oldChildren[i].remove();
}
}
} else {
Reconciler.mountElement(virtualElement, container, oldDomElement);
}
},

Next, let's inspect Reconciler.diffComponent(). It takes the new virtual element, an optional old component, the container, and the DOM element. Now, remember that with components, the virtualElement.type refers to the constructor of the component. To know if the new component will be of the same type as the previous one, we can compare the new constructor to the old constructor.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
diffComponent: (newVirtualElement, oldComponent, container, domNode) => {
if (
// are these the same constructor
oldComponent && newVirtualElement.type === oldComponent.constructor
) {
// update component
oldComponent.updateProps(newVirtualElement.props);
const nextElement = oldComponent.render();
Reconciler.diff(nextElement, container, domNode, oldComponent);
} else {
Reconciler.mountElement(newVirtualElement, container, domNode, parentComponent);
}
}

Updating a component

If the constructors are the same, we can update the component. This involves updating the props and re-rendering the component. Let's add the updateProps() method to the Component superclass.

1
2
3
updateProps(newProps) {
this.props = newProps;
}

After updating the props, we call render() on the component, which returns a new virtual element that represents an actual DOM element (it could be another component too, but we'll handle this edge case later). Then we continue diffing by calling Reconciler.diff() with the new virtual element.

Mounting a component

Let's first see how we mount a component into the DOM. First, we split the Reconcile.mountElement() to two functions: mountComponent() and mountSimpleNode(). We move the code we wrote for mountElement() in part 1 to mountSimpleNode() and create a new method mountComponent().

1
2
3
4
5
6
7
mountElement: (element, container, oldDomNode, parentComponent) => {
if (typeof element.type === 'function') {
Reconciler.mountComponent(element, container, oldDomNode, parentComponent);
} else {
Reconciler.mountSimpleNode(element, container, oldDomNode);
}
}

mountComponent() is very straight-forward. We create a new component from the constructor, which, I hope you remember, is stored in element.type. Then we call render() on the component which returns a new virtual element. Before continuing with Reconciler.diff, we store a reference to the component in the virtual element.

1
2
3
4
5
6
7
8
mountComponent: (virtualElement, container, oldDomElement, parentComponent) => {
const component = new virtualElement.type(virtualElement.props);
const nextElement = component.render();
nextElement.component = component;
Reconciler.diff(nextElement, container, oldDomElement);
}

That was easy! Now we have a framework that can handle components as well as simple elements. We're done with this part, right? Well, not quite...

Nested Components

There's an annoying edge case we have to handle. Sometimes, a component doesn't return a simple element but another component. For example, we might have a MessageContainer component that renders a Message component. MVR must support an arbitrary degree of nesting like this.

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
class Message extends MVR.Component {
render() {
return (
MVR.createElement('div', {}, [
MVR.createElement('p', {}, [
this.props.text,
MVR.createElement('span', {}, [
' World'
])
]),
MVR.createElement('button', {
onClick: this.props.onButtonClick
}, [
'click me'
])
])
);
}
}
class MessageContainer extends MVR.Component {
render() {
return <Message />
}
}

This could be solved in various ways, but in MVR the solution is to store a reference to the parent component into the first virtual element. In other words, we form a linked list of sorts, where the first item is the parent component. The diagram below will make this clearer.

VDOM diagram with components

Now, let's update mountComponent() and diffComponent() to handle nested components. But first, let's add a couple of methods to our Component superclass, namely getChild() and setChild().

1
2
3
4
5
6
7
8
9
10
11
12
13
class Component {
...
setChild(component) {
this._child = component;
}
getChild() {
return this._child;
}
...
}

In diffComponent(), we have to check whether the component has child components , and if it does, call diffComponent() recursively until we get a childless component.

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
diffComponent: (newVirtualElement, oldComponent, container, domElement, parentComponent) => {
if (
oldComponent &&
newVirtualElement.type === oldComponent.constructor
) {
// update component
oldComponent.updateProps(newVirtualElement.props);
const nextElement = oldComponent.render();
nextElement.component = parentComponent || oldComponent;
const childComponent = oldComponent.getChild();
if (childComponent) {
Reconciler.diffComponent(
nextElement,
childComponent,
container,
domElement,
oldComponent
);
} else {
Reconciler.diff(nextElement, container, domElement, oldComponent);
}
} else {
Reconciler.mountElement(newVirtualElement, container, domElement, parentComponent);
}
}

In mountComponent(), we check if the virtual element returned by render() is a component, and if it is, call mountComponent() recursively until we get a simple element. We also have to set up the linked list I mentioned earlier using setChild().

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
mountComponent: (virtualElement, container, oldDomElement, parentComponent) => {
const component = new virtualElement.type(virtualElement.props);
const nextElement = component.render();
if (parentComponent) {
const root = parentComponent.getRoot();
nextElement.component = root;
parentComponent.setChild(component);
} else {
nextElement.component = component;
}
if (typeof nextElement.type === 'function') {
Reconciler.mountComponent(nextElement, container, oldDomElement, component);
} else {
Reconciler.diff(nextElement, container, oldDomElement);
}
}

Conclusion

That's it! MVR now has support for components. We can already build pretty complicated apps with this framework, but a few critical things are missing, namely:

  1. Lifecycle hooks
  2. setState()
  3. Better list reconciliation (using keys)

We'll tackle items 1 and 2 in the next edition of Minimum-Viable-React, and in the 4th and last part we'll add a better list reconciliation algorithm. I hope you learned something from this and remember to leave a comment if you have any feedback or questions.