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

Build Your Own React - Part 1: Simple Elements

In this series of articles, we'll build a simple React-like framework. I call it Minimum Viable React, or MVR for short, because it implements all the mechanics necessary for a React-like development experience while leaving out some of the more advanced features and optimizations. Here's a list of some of the major features we'll leave out:

  • Synthetic events
    React implements its own event system, which is mostly an optimization as well as a technique to add support for IE8.
  • Styles
    You can still pass styles as a string to a component, the same way you can add style attributes to HTML elements. We won't, however, bother with parsing a style object the way React does it.
  • JSX
    JSX would be a whole series of articles in and of itself. However, we implement the React API which means you can use MVR with JSX.

You may notice that this looks a lot like Preact. And you'd be right in thinking that. However, in MVR we ignore some edge cases and optimize the codebase for readability as opposed to minimizing compute cycles and memory usage.

First MVR App

Let's take a look at our first MVR app.

Not very exciting, I know, but we have to start somewhere.

Let's look at the code. As you can see, this looks just like React. However, in this first part, we don't have components and the whole app is composed out of simple elements. Those elements can be nested, and they can have event handlers attached to them. We'll look at how to implement those later.

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
import MVRDom from './MVRDom';
import MVR from './MVR';
function render(text, container) {
MVRDom.render(
MVR.createElement('div', {}, [
MVR.createElement('h1', {}, [
'First MVR App'
]),
MVR.createElement('p', {}, [
text,
MVR.createElement('span', {}, [
' World'
])
]),
MVR.createElement('button', {
onClick: () => {
const newText = text === 'Hello' ? 'Goodbye' : 'Hello';
render(newText, container);
}
}, [
'click me'
])
]),
container
);
}
window.FirstMVRApp = (container) => {
render('Hello', container);
};

In the first two lines of the app we import MVRDom.js and MVR.js. These files contain the entirety of the MVR framework.

MVR.js

This is all the code in MVR.js for now. As you can see, createElement() just constructs a simple object and returns it. From this point on, we'll call objects returned by createElement() virtual elements, as opposed to actual DOM-elements. Virtual elements constitute the virtual DOM in MVR. They represent the desired state of the actual DOM.

In the final version, there will be three types of virtual elements:

  • strings
  • simple elements
  • components

In this first version we are only concerned about the first two.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const MVR = {
createElement: (type, attributes = {}, children = []) => {
const childElements = children.map(child => (
typeof child === 'string' ?
MVR.createElement('text', { textContent: child }) :
child
));
return {
type,
children: childElements,
props: Object.assign(
{ children: childElements },
attributes
)
};
}
};
export default MVR;

High-Level View

Let's step back and talk about what we're trying to achieve here. The main idea of React is to maintain a separate representation of the DOM in memory. This is commonly called the virtual DOM (or VDOM). Then, every time we render, we render the whole app by comparing the new virtual DOM to the old one, and only touch the real DOM when we find a change.

Now, where should we store the VDOM and how do we keep track of which real DOM-element each virtual element represents? It turns out we can store a reference to the corresponding virtual element in the real DOM-element itself. Like this:

1
2
const domNode = document.createElement(element.type);
domNode._element = element;

This may seem a bit hacky at first glance, but it's actually what React does. If you don't believe me, open any React app and inspect one of the elements in the chrome dev-console. You'll see that all the DOM-nodes have a _reactInternalInstance property.

So, in reality, VDOM and DOM are not two completely different data-structures but VDOM is a sort of an extension of the real DOM.

VDOM diagram

MVRDom.js

MVRDom.js is where the main logic lives, although the external API is very simple - it consists of a single render method, which just delegates to an object we call Reconciler.

1
2
3
4
5
export default {
render: (element, container) => {
Reconciler.diff(element, container, container.firstChild);
}
};

Reconciler has the following methods:

  • diff()
  • updateTextNode()
  • updateDomElement()
  • mountElement()

We'll look at each method individually.

Reconciler.diff

It's best to start with Reconciler.diff. It takes a virtual element, created by React.createElement(), a container node, and the old DOM element that we either replace or update. Note, that the old DOM element doesn't necessarily exist. For instance, the first time ReactDom.render is called the DOM is empty and naturally, there's no old DOM element to compare to.

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) => {
const oldVirtualElement = oldDomElement && oldDomElement._virtualElement;
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);
}
}

Here, we walk through the DOM and, at each node, check if the new virtual element has the same type as the old virtual element. If it does, we update the corresponding DOM element using Reconciler.updateTextNode() or Reconciler.updateDomElement(), depending on the type of the element. Otherwise, we insert it into the DOM. In the case that we update an existing DOM element, we call Reconciler.diff() recursively with the element's children. We'll also have to remove extra children from the DOM in case the new virtual element has fewer children than what is currently in the DOM.

This version uses a very straightforward list reconciliation strategy where we simply compare the child element to the virtual element of the DOM element at the same index. In future posts, we'll create a better, more sophisticated algorithm using keys.

Reconciler.mountElement()

mountElement() creates either a new text-node or a DOM element depending on the type of the virtual element. Then it removes the old DOM element and inserts the new DOM element into the DOM. Note that we also store the virtual element in the new node as _virtualElement, which allows us to easily retrieve it on the next render and compare the props. At the end of the function, we call mountElement() with all the child elements.

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
33
mountElement: (virtualElement, container, oldDomElement) => {
let newDomElement;
const nextSibling = oldDomElement && oldDomElement.nextSibling;
if (virtualElement.type === 'text') {
newDomElement = document.createTextNode(virtualElement.props.textContent);
} else {
newDomElement = document.createElement(virtualElement.type);
// set dom-node attributes
Reconciler.updateDomElement(newDomElement, virtualElement);
}
// save the element on the domElement
// so that we can retrieve it next time
newDomElement._virtualElement = virtualElement;
// remove the old node from the dom if one exists
if (oldDomElement) {
oldDomElement.remove();
}
// add the newly created node to the dom
if (nextSibling) {
container.insertBefore(newDomElement, nextSibling);
} else {
container.appendChild(newDomElement);
}
// recursively call mountElement with all child elements
virtualElement.children.forEach((childElement) => {
Reconciler.mountElement(childElement, newDomElement);
});
}

Reconciler.updateTextNode()

1
2
3
4
5
6
7
8
9
updateTextNode: (domElement, newVirtualElement, oldVirtualElement) => {
if (newVirtualElement.props.textContent !== oldVirtualElement.props.textContent) {
domElement.textContent = newVirtualElement.props.textContent;
}
// save a reference to the virtual element into the domElement
// so that we can retrieve it the next time
domElement._virtualElement = newVirtualElement;
},

updateTextNode() is pretty self-explanatory. It replaces the text if it has changed since the previous render.

Reconciler.updateDomElement()

updateDomElement() has to update all event-listeners and attributes, as well as take care of a few edge cases.

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
33
34
35
36
37
38
39
40
41
updateDomElement: (domElement, newVirtualElement, oldVirtualElement = {}) => {
const newProps = newVirtualElement.props;
const oldProps = oldVirtualElement.props || {};
Object.keys(newProps).forEach((propName) => {
const newProp = newProps[propName];
const oldProp = oldProps[propName];
if (newProp !== oldProp) {
if (propName.slice(0, 2) === 'on') {
// prop is an event handler
const eventName = propName.toLowerCase().slice(2);
domElement.addEventListener(eventName, newProp, false);
if (oldProp) {
domElement.removeEventListener(eventName, oldProp, false);
}
} else if (propName === 'value' || propName === 'checked') {
// this are special attributes that cannot be set
// using setAttribute
domElement[propName] = newProp;
} else if (propName !== 'children') { // ignore the 'children' prop
domElement.setAttribute(propName, newProps[propName]);
}
}
});
// remove oldProps
Object.keys(oldProps).forEach((propName) => {
const newProp = newProps[propName];
const oldProp = oldProps[propName];
if (!newProp) {
if (propName.slice(0, 2) === 'on') {
// prop is an event handler
domElement.removeEventListener(propName, oldProp, false);
} else if (propName !== 'children') { // ignore the 'children' prop
domElement.removeAttribute(propName);
}
}
});
}

This function is a bit long so let's break it down. First, we iterate over the props of the new virtual element (line 5). Each prop is compared to the same prop of the previous virtual element. If it was changed, we update the DOM accordingly. Next, we iterate over the old props (line 28) and remove all attributes that no longer exist.

Events

To identify a prop that represents an event we look at the first two letters of the prop name (line 10). If the first two letters spell 'on', we assume that the prop is an event handler. Then we attach that handler to the DOM-node and remove the previous handler. It's that easy!

Edge Cases

The two edge case attributes we have to pay attention to are value and checked (line 17). These attributes cannot be updated via setAttribute but instead are properties on the DOM-node itself that we have to reassign.

Conclusion

That's all the code it takes to implement a simple virtual DOM. Hopefully, you can see that it's not that complicated once you realize that it's possible to store the virtual elements within the DOM elements themselves, and then just traverse the DOM and check for updates. Of course, this implementation lacks a critical feature of React: components. That's the topic of part 2 of this series.