Welcome back to the Babbel Bytes series on “Build your own (simple) React from scratch!”. After tackling the rendering of components in Episode 1, we will now look into a crucial part of React, the Virtual DOM (VDOM).
Firstly, let’s clarify what the non-virtual DOM is. The Document Object Model (DOM) is an interface that represents the structure and content of a web page in the form of nodes and objects. This allows programming languages to interact directly with the elements of the page.
The VDOM then is a DOM-like structure that allows React to keep a representation of the UI in memory and synced with the actual DOM.
It is useful for two primary reasons:
- It allows the application of targeted updates to the DOM to improve performance.
- Enables the storing and tracking of additional information about the component tree, such as state.
What will our VDOM look like?
Considering those two primary reasons for having a VDOM, we need to create a structure that allows us to refer to elements by their position within the tree. We also need to be able to retrieve those elements easily.
To do that, we will create a system to identify each element within the VDOM. We will create a unique ID for each of our elements, based on their position within the tree. We will refer to this ID as a pointer from now on, as it will help us point to a specific element in the tree in order to retrieve it.
Let’s think about an example:
<div>
<div>
<span>
Hello world!
</span>
</div>
<span>
Another span!
</span>
</div>
In this simple example, when we think about the path of “Hello world!”, it consists of four nodes – two div
HTML tags, one span
HTML tag, and a final primitive text element “Hello world!”.
Let’s think about the DOM above as a tree. Note, that a “parent” is an element that is directly above and connected to a “child” element in the document tree.
Given we want to use positions to identify the DOM elements, we could denote each of them based on their position from the root. This position corresponds to the index of the element in its parent’s array of children.
The different type of elements in our structure
Most of the potential elements in the tree can be handled within our DOM handlers rendering method, which ultimately returns an object (a DOM node) that can be inserted into the DOM.
For example, tag elements are rendered as their type in the dom, and their props are applied to the HTML tag element.
The majority of primitive types are rendered as text nodes, except for undefined
/false
, which we don’t render (but will need to keep track of in the future so we can apply node additions/removals correctly).
The trickiest elements to deal with are Functional Components. While they are very useful in allowing us to easily reuse code and integrate hooks, they aren’t useful for the DOM handlers. Why? They can’t be rendered to the browser’s DOM directly.
This marks an important distinction that we make between:
- The “regular” VDOM – It will include all elements in our app (tags, primitives and components). We will use it solely in our
index
to avoid leaking React-specific information to ourDOMHandlers
. There will be two versions of it: the one from the current render and one from the previous render so we can keep track of changes between renders. - The “renderable” VDOM – Excludes functional components in favor of their renderable children, as the components themselves aren’t very useful to the DOM renderer. This enables us to lower the complexity in the DOM handlers by separating concerns: DOM specific rendering is contained within the DOM handlers, whereas React/Component specific details are contained within the
index.js
file.
Let’s look at an example, given the following JSX:
<div>
<h1> Build your own (simple) React!</h1>
<section>
<Counter />
</section>
</div>
and the following Counter
component:
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<span>The count is {count}</span>
<button onClick={() => setCount(
count => count + 1
)}>+</button>
</div>
);
}
our final DOM in the browser should look like this on the first render:
<div>
<h1> Build your own (simple) React!</h1>
<section>
<div>
<span>The count is 0</span>
<button>+</button>
</div>
</section>
</div>
In the rendered version, components are not part of the picture as they are rendered into actual HTML, but we will still need to keep track of them in our full VDOM so that we can process diffs (difference between previous and next states) and keep hooks updated.
Data structure of our VDOM
We need to create a data structure that will allow us to store in memory our VDOM and set or retrieve an element when given a specific pointer. We also need to keep components within it, as we will need to know when they appeared and when they were removed from the tree to correctly handle their hooks.
This led us to the following structure for the full VDOM:
With that structure, we are able to:
- Represent the nested structure of our elements, including components.
- Keep all props and original JSX objects intact while also tracking the result of transforming children into their renderable versions.
Now to make our lives easier when traversing this, we also decided to have pointers as arrays as they let us map over them and retrieve elements conveniently by referencing their index. So in the case above, if we wanted to point to the counter button (for example to read or write its value) , we would select:
- The second child of the root: the
section
, which is at index 1 in the root - The first child of the
section
: theCounter
, so index 0 - The first child of the
Counter
: thediv
, again index 0 - The second child of the
div
: thebutton
, index 1
As an array this would give [1, 0, 0, 1]
, which then points to and retrieves the button in the VDOM above.
To standardize the code to access and set VDOM elements, we wrote the following helper functions in vdom-helpers.js
(We’ve added types below to provide more insight into each element):
// Gets the VDOMElement from the provided VDOM pointed by the given VDOMPointer
export const getVDOMElement = (pointer: VDOMPointer, currentVDOM: VDOM): VDOMElement
// Sets the VDOMElement in the provided VDOM.current at the given VDOMPointer
export const setCurrentVDOMElement = (pointer: VDOMPointer, element: VDOMElement, vdom: VDOMWithHistory)
// Creates a VDOMElement based on the provided JSXElement and VDOMPointer
export const createVDOMElement = (element: JSXElement, pointer: VDOMPointer): VDOMElement
type VDOMPointer = number[];
type FunctionComponent = (props: Record<string, unknown>): JSXElement;
type TagElement = 'div' | 'span' | 'button' | 'h1' /* ... */;
type BaseVDOMElement = {
VDOMPointer: VDOMPointer
};
type VDOMElementWithProps = {
props: Record<string, unknown>
} & BaseVDOMElement;
type ComponentVDOMElement = {
type: FunctionComponent
} & BaseVDOMElement;
type TagVDOMElement = {
type: TagElement
} & BaseVDOMElement;
type PrimitiveVDOMElement = {
type: 'primitive',
value: string | number | boolean | undefined;
} & BaseVDOMElement;
type VDOMElement = ComponentVDOMElement | TagVDOMElement | PrimitiveVDOMElement;
type VDOMTree {
element: VDOMElement,
renderedChildren: VDOMTree[]
};
type VDOMWithHistory = {
current: VDOM,
previous: VDOM
};
So now if we want to get that button, we could just do:
getVDOMElement([1, 0, 0, 1], vdom.previous);
And if we wanted to replace it with a span element in the current VDOM, we would do:
const pointer = [1, 0, 0, 1];
const spanVDOMElement = createComponentVDOMElement({
children: 'Replaced!',
className: 'super'
}, 'span', pointer);
setCurrentVDOMElement(pointer, spanVDOMElement, vdom);
Why are we keeping renderedChildren
as a separate entity, and not just replacing the props.children
?
In the future, we will want to be able to diff previous props with next props, so it would be useful to still have a reference to the original children that are currently rendered in the DOM.
From the full VDOM above, the renderable VDOM would get rid of components and would use mutated props.children
to represent the final children (we recreate the renderable VDOM on every update, so mutating props is ok within it).
Here is what the renderable VDOM from above would look like:
NB: we keep the VDOMPointer as references to the original VDOM so that we can correctly understand where children originally came from.
Which brings us to the end of this episode. We have learnt that the VDOM is a “virtual” representation of a UI, kept in memory and synced with the “real” DOM. It enables those UI’s to be more performant through targeted updates and allows the persistence of key information between renders. We’ve also detailed our own distinction between a full VDOM and the renderable VDOM, which excludes functional components as they cannot be rendered to the DOM. Lastly we defined the term pointers, which are arrays that map to an element’s position within the tree, so we can easily retrieve that element.
We are now ready to move on and start adding state to our ToDo app…and to do that we are going to meet and create our first React hook of the series, useState
. All of that in Episode 3: Let’s get stateful.
This article was written as a trio in collaboration with Ömer Gülen and Jean Duthon.