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

Build Your Own React - Part 4: Lists and Keys

The last and final feature we'll add to Minimum Viable React are keys. By keys, I refer to the key attribute that you should assign to components or simple elements when rendering lists.

Why are keys necessary?

The framework needs a way to know whether it should replace a component or update an existing one. So far, the decision has only been based on the type of the new virtual element and the existing component. Remember that if a virtual element represents a component, the type refers to the constructor of that component. This heuristic is insufficient when we want to render a list of components that all have the same type.

Here's a quick example of why we need keys. You might remember this app from part 3. I mentioned that the app has a bug. Let's look at that bug more closely now.

Try clicking the 'Show Logo' button for New York. Then, sort the list by the number of lines by clicking the header that says 'lines'. You'll see that the rows are now ordered by the number of lines, but the New York City Subway logo is hidden and the logo for Paris Métropolitain shows up instead. This happens because MVR has no way of knowing that Paris and New York should be two instances of the same component so it just updates each component with new props (New York becomes Paris). Keys provide that information.

The algorithm

In the past, we've just iterated through the children of each virtual element and diffed them against the DOM nodes at the same index.

1
2
3
4
5
6
7
8
9
diff: (virtualElement, container, oldDomElement, parentComponent) => {
...
virtualElement.children.forEach((childElement, i) => {
Reconciler.diff(childElement, oldDomElement, oldDomElement.childNodes[i]);
});
...
}

Now, we'll replace this simple algorithm with a more complex one where we compare the keys of the elements. The code for this is pretty long so I'll try to give a good summary first. The steps are:

  1. Iterate through the DOM elements we want to diff against and divide them into keyed and unkeyed elements.

  2. Iterate through the virtual elements and treat keyed and unkeyed elements separately.

    For keyed elements:

    If a DOM element with the same key exists, move it to its correct position (index of the virtual element), and diff them as per usual. Otherwise, create a new DOM element.

    For unkeyed elements:

    Diff against the DOM element at the same index in the unkeyed DOM elements array. If a DOM element at that index doesn't exist, create a new DOM element.

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
diffList: (virtualElements, parentDomElement) => {
const keyedElements = {};
const unkeyedElements = [];
for (let i = 0; i < parentDomElement.childNodes.length; i += 1) {
const domElement = parentDomElement.childNodes[i];
const key = Reconciler.getKey(domElement._virtualElement);
if (key) {
keyedElements[key] = domElement;
} else {
unkeyedElements.push(domElement);
}
}
let unkeyedIndex = 0;
virtualElements.forEach((virtualElement, i) => {
const key = virtualElement.props.key;
if (key) {
const keyedDomElement = keyedElements[key];
if (keyedDomElement) {
// move to correct location
if (
parentDomElement.childNodes[i] &&
!parentDomElement.childNodes[i].isSameNode(keyedDomElement)
) {
if (parentDomElement.childNodes[i]) {
parentDomElement.insertBefore(
keyedDomElement,
parentDomElement.childNodes[i]
);
} else {
parentDomElement.append(keyedDomElement);
}
}
Reconciler.diff(virtualElement, parentDomElement, keyedDomElement);
} else {
const placeholder = document.createElement('span');
if (parentDomElement.childNodes[i]) {
parentDomElement.insertBefore(placeholder, parentDomElement.childNodes[i]);
} else {
parentDomElement.append(placeholder);
}
Reconciler.mountElement(virtualElement, parentDomElement, placeholder);
}
} else {
const unkeyedDomElement = unkeyedElements[unkeyedIndex];
if (unkeyedElements) {
if (
parentDomElement.childNodes[i] &&
!parentDomElement.childNodes[i].isSameNode(unkeyedDomElement)
) {
if (parentDomElement.childNodes[i]) {
parentDomElement.insertBefore(
unkeyedDomElement,
parentDomElement.childNodes[i]
);
} else {
parentDomElement.append(unkeyedDomElement);
}
}
Reconciler.diff(virtualElement, parentDomElement, unkeyedDomElement);
} else {
const placeholder = document.createElement('span');
if (parentDomElement.childNodes[i]) {
parentDomElement.insertBefore(placeholder, parentDomElement.childNodes[i]);
} else {
parentDomElement.append(placeholder);
}
Reconciler.mountElement(virtualElement, parentDomElement, placeholder);
}
unkeyedIndex += 1;
}
});

Now, we can update the metro app to use keyed components and the bug is gone. Each component retains its state and the correct logos remain visible even after the list is sorted.

MVR ToDo

Support for keys was the last requirement for a ToDo app. So here it is: a ToDo app built with Minimum-Viable-React.

Congratulations if you've made it this far. This certainly was a much more elaborate undertaking than I first thought and I'm the first one to admit that I underestimated the complexity of the project. The final MVR framework is just shy of 500 lines of code, which is minuscule compared to React itself, but, obviously, we've only implemented a small subset of React's features. Although, I'd argue, the most important ones.

Check out the source code for MVR if you want to investigate further.