Welcome back to the Babbel Bytes blog series on “Build your own (simple) React from scratch!”. In this series, we are taking a deep dive into the intricacies of React and building our own version of it based on a workshop originally created for React Day Berlin 2022.
In case you jump here out of order, you can go to the article you wish to continue with the links in the following paragraph.
In the previous articles, we implemented our very own DOM handlers in order to render JSX to the DOM (episode I), and we took a theoretical look at the idea of a Virtual DOM as a means to keep track of updates and avoid expensive computation in the real DOM (episode II). Then, we implemented our first hook: useState, and started making our components a bit more useful (episode III).
In this episode, we will dive into the concept of diffing, an essential technique for optimizing the performance of our custom React implementation.
But why?
In our app’s current state of implementation, whenever we enter a key to the text input field, the app loses focus. This is obviously not what we want, but why is it happening?
The answer is that our App loses focus to the active input field on each keystroke, because we are rendering the whole application from scratch on each state update. In other words, since our React implementation doesn’t know which part of the state has changed on the keystroke, we just use the new VDOM state to recreate the whole DOM from scratch and replace it with the existing one. And, yes, this is not the best way to do it 🙂
DOM operations force the browser to re-render UI which involves much more computer power compared to only changing those in memory. And, recreating the whole DOM and replacing it with the existing one could be one of the costliest ways to handle state updates.
Let’s jump into the solution of how we are going to tackle this performance and user experience issue.
Reconciliation
Reconciliation is the process of efficiently updating the DOM when the application state changes. Whenever there’s a change in the state, our React has to determine which DOM elements must be updated, added, or removed to accurately reflect the new state.
Performing this process naively can lead to slow and unresponsive user interfaces, as updating the DOM is computationally expensive and time-consuming.
The key to efficient reconciliation lies in minimizing the number and scale of updates performed on the DOM. To achieve this, our React should compare the old and new VDOM trees to find the minimum set of changes needed to bring the actual DOM in sync with the new VDOM. This is where diffing comes into play, providing a way to determine the differences between the two VDOM trees.
A little more about Diffing
Diffing is the process of comparing two data structures to identify their differences. In our context, diffing is utilized to compare the old and new VDOM trees to pinpoint the changes required to update the actual DOM. By determining the smallest set of updates needed, our React implementation can gain a significant advantage and minimize DOM updates. The diffing algorithm works by traversing both the previous and new VDOM trees, comparing nodes at each level. If a difference is detected, React records the change and continues the traversal. Once the entire tree has been traversed, we should apply the recorded changes to the actual DOM, resulting in a highly efficient update process.
When it comes to diffing, there are two primary strategies for traversing trees: depth-first and breadth-first. Depth-first traversal involves visiting all the children of a node before moving on to its siblings, while breadth-first traversal visits all siblings at the same level before progressing to the next level.
Both strategies have their advantages and drawbacks. Depth-first traversal can identify changes faster, as it reaches the leaf nodes earlier in the process. However, it can also lead to more expensive updates, as changes to a single node can cascade down the entire tree. Breadth-first traversal, on the other hand, spreads the cost of updates more evenly across the tree but may take longer to complete due to the additional time spent traversing siblings at each level.
In practice, React employs a custom algorithm to calculate the changes list along with several optimizations to improve the efficiency of the diffing process. Yet, in our implementation we’ve adopted only a depth-first approach for the sake of simplicity.
Instead of providing more theoretical details, let’s jump into the implementation and explore the solution while getting our hands dirty.
Let’s implement it
It is understandable that this looks complicated to do in one go. But, don’t worry, in this episode we will introduce new functionalities to our implementation in small achievable steps and we will be providing the complex parts of the code as a scaffold.
In this episode we will be adding the following functionality to our implementation to achieve performant updates using diffing in two-step process:
- Reconciliation: Calculating the difference between the previous and new VDOM when an update occurs and creating a list of changes that need to be applied
- Patching: Applying the calculated minimal changes to the DOM considering the priority.
Step 1
> If following along, it is now time to go to the branch git checkout chapter-3/step-1.
On this new branch, we have set up the starting point (START HERE comment in the code) and few other small challenges (DON’T FORGET comments in the code) for diffing to work as we described above.
The biggest difference compared to how we have worked so far is that our React will now have a new module called “diff” with multiple objects and functions inside. For now, most of it is just helpers for the getRenderableVDOMDiff
function which calculates the difference between previous and new VDOM.
You have probably spotted that this function is being used in our render function in index.js
> startRenderSubscription
> update
> getRenderableVDOMDiff
. For now, we are only logging it; in the next step, we will start using it.
// this function will call the updateCallback on every state change
// so the DOM is re-rendered
export const startRenderSubscription = (element, updateCallback) => {
let vdom = {
previous: {},
current: {},
};
const update = (hooks) => {
const renderableVDOM = rootRender(element, hooks, vdom);
// We calculate the diff between the renderableVDOM and the previous VDOM here
const diff = getRenderableVDOMDiff(renderableVDOM, vdom);
// For now we only log it, in the next step we will start using it
console.log(diff);
vdom.previous = vdom.current;
vdom.current = [];
updateCallback(renderableVDOM, diff);
};
const hooks = createHooks(update);
update(hooks);
};
To understand what is going on inside the diff module, let’s dive into the details of the diffing we wanted to achieve in this episode.
In our implementation of React, we start comparing the root nodes and go down the tree recursively. In our basic implementation, we are classifying the changes that could happen to a node into 5 categories (in the code):
- Node Added: If there is no previous element at the position for which there is a current element, we classify this change as a new node added at the specific position.
2. Node Removed: If there was a previous element at the position for which there is no current element, we classify this change as the node removed from that position.
3. Node Replaced: If there was a previous element at the position for which there is a current element with different type, we classify this change as the node replaced by another node.
4. Primitive Node Updates: If there was a previous primitive element at the position for which there is a current primitive element with different content, we classify this change as the content of the primitive element has been updated.
5. Props Update: If none of the conditions above apply to our element, this means our element wasn’t recently added, removed, nor replaced, or the content of it has not changed. In other words, this node is the same from the previous VDOM, then we can start calculating the diff of their properties (excluding children
property, we will get to that later – in the code). The property diffs have two types (in the code):
a) Property Removed: If the value of a property was not undefined
in the previous VDOM, and it is undefined
in the new VDOM, we classify this change as the property has been removed from the element.
b) Property Updated: If the property is not removed, but its value has been changed (a new prop addition is also included in update in this case), we classify this change as an update.
We have created helper functions to create diff items easily, and keep the diff function cleaner. Each diff item has the following properties:
type
– to understand what kind of change happenedVDOMPointer
– to figure out on which node it happenedpayload
– for passing the necessary information to apply the changes.
For the Node Added diff, we need to pass what to render, and its parentPointer so we can actually find the parent and add the new node as a child.
const createNodeAddedPayload = (
currentRenderableVDOMElement,
parentPointer,
) => ({
VDOMPointer: currentRenderableVDOMElement.VDOMPointer,
type: diffType.nodeAdded,
payload: { node: currentRenderableVDOMElement, parentPointer },
});
Meanwhile we only need to know the VDOMPointer of the node to remove it.
const createNodeRemoved = currentRenderableVDOMElement => ({
VDOMPointer: currentRenderableVDOMElement.VDOMPointer,
type: diffType.nodeRemoved,
payload: {},
});
Node Replaced will be applied as a combination of Node Removed and Node Added respectively, that’s why the diff item is a combination of those two.
const createNodeReplaced = (
currentRenderableVDOMElement,
parentPointer,
) => ({
VDOMPointer: currentRenderableVDOMElement.VDOMPointer,
type: diffType.nodeReplaced,
payload: {
newNode: currentRenderableVDOMElement,
parentPointer,
},
});
For the Primitive Node Updates, we pass the newElement
so that we can update the element at the VDOMPointer with its value.
const createPrimitiveNodeUpdate = (
currentRenderableVDOMElement,
newElement,
) => ({
VDOMPointer: currentRenderableVDOMElement.VDOMPointer,
type: diffType.primitiveNodeUpdate,
payload: { newElement },
});
For each type of prop diff (remove and update), we create a new sub-diff item and then wrap it into one diff item using createPropsDiff
. We are passing the oldValue
here because we’d need it to remove existing event listeners if they are removed or updated.
const createPropsDiffTypeRemoved = (
oldValue
) => ([
propsDiffType.removed,
{ oldValue },
]);
const createPropsDiffTypeUpdated = (
newValue,
oldValue
) => ([
propsDiffType.updated,
{ newValue, oldValue },
]);
const createPropsDiff = (
currentRenderableVDOMElement,
changedProps
) => ({
VDOMPointer: currentRenderableVDOMElement.VDOMPointer,
type: diffType.props,
payload: changedProps,
});
Now, let’s take a look at the function where the diffing actually takes place, the getRenderableVDOMDiff function, it returns the diff items for a given node by going down the tree recursively.
It is worth noting that this function returns an array, because when we compare the previous and current VDOM nodes, there is a possibility of 0 to N changes that we may encounter, and since this function constantly calls itself recursively through node’s children until it coincides with leaf, we return the diffs in array no matter how many changes we encountered. Then, we concatenate it with previous diffs when we are going back in the call stack:
export const getRenderableVDOMDiff = (
currentRenderableVDOMElement,
vdom,
parentPointer
) => {
// Retrieve the previous element in the VDOM for comparison
const prev = getVDOMElement(
currentRenderableVDOMElement.VDOMPointer,
vdom.previous
);
// If there is no previous element at the position for which there is a current element
if (!prev) {
// START HERE
// What type of diff is this?
return [];
}
// ...
};
In this function we will be touching the parts where we decide if there is a diff between current and previous nodes, and which kind.
At this point, you have all the information you need to tackle down the first challenge (marked with the START HERE comment), if you are coding along feel free to stop reading here, and come back once you get it working.
The solution to the first challenge is a diff of type node added. We can see that as the recursive function is called with the current renderable element, but we do not have a matching previous element, meaning there used not to be an element at this position before.
We simply invoke createNodeAddedPayload
with currentRenderableVDOMElement
, which represents the current element, and parentPointer
, which is the pointer to the parent element. This will allow us to easily add the new element using its parent.
if (!prev) {
return [
createNodeAddedPayload(currentRenderableVDOMElement, parentPointer)
];
}
In the following block we access elements themselves, and compare their types to detect replaced nodes. The logic is pretty straightforward here, the type is either primitive
(for example for text), an HTML tag, or a component (so a function), so we can simply use standard JavaScript comparison to understand whether the previous element is of the same type as the current one.
// Access the actual elements to compare them
const prevElement = prev.element;
// renderableVDOM element don't have renderedChildren, so they are already the element
const currElement = currentRenderableVDOMElement;
if (prevElement.type !== currElement.type) {
return [
createNodeReplaced(
currentRenderableVDOMElement,
parentPointer
),
];
}
Then, we check if the previous and current elements are primitive elements, and if their values have changed. If you are coding along, this is a good place to stop reading, solve the challenge by replacing the REPLACE_THIS_CONDITION
with necessary conditions, and come back once it is working.
// my-own-react/vdom-helpers.js
const PRIMITIVE_TYPE = 'primitive';
export const isPrimitiveElement = element => element.type === PRIMITIVE_TYPE;
// If both primitive
if (isPrimitiveElement(prevElement) && isPrimitiveElement(currElement)) {
// DON'T FORGET
// Under which condition is a primitive node updated?
// A primitive element has the following structure
// { type: 'primitive', value: number | string | boolean | undefined }
const REPLACE_THIS_CONDITION = false;
if (REPLACE_THIS_CONDITION) {
return [
createPrimitiveNodeUpdate(currentRenderableVDOMElement, currElement),
];
}
// no change
return [];
}
We already know that both elements are primitive, meaning their values are actual primitive values such as numbers, strings, booleans, and undefined. Therefore, we can easily compare them using the strict equality operator (===
) and create a diff only if they are not strictly equal.
if (prevElement.value !== currElement.value) { // ...
For the props, we iterate through the union set of the property keys, and create sub diff items for the changed properties. Pay attention to the key children
, since we are going to handle the node’s children in a recursive way later on, we are just skipping them here.
// Prepare props diff item
const changedProps = {};
// Collect all props keys from the previous and current element
const keys = Array.from(
new Set([
...Object.keys(prevElement.props),
...Object.keys(currElement.props),
])
);
// Loop through the props keys
for (const key of keys) {
// Children is a special prop as it requires us to go recursive so we ignore it here
if (key === 'children') {
continue;
}
const currentPropValue = currElement.props[key];
const previousPropValue = prevElement.props[key];
// If the current props have no reference to the prop we evaluate
if (typeof currentPropValue === 'undefined') {
changedProps[key] = createPropsDiffTypeRemoved(previousPropValue);
continue;
}
// If the current and previous prop value aren't the same
if (currentPropValue !== previousPropValue) {
changedProps[key] = createPropsDiffTypeUpdated(
currentPropValue,
previousPropValue
);
}
}
If we have a change in the properties, we push that diff item into the diff
array.
let diff = [];
// conditional case to keep output clean
if (Object.keys(changedProps).length > 0) {
diff.push(createPropsDiff(currentRenderableVDOMElement, changedProps));
}
At this moment, we need to go through all the children of the nodes (in the code), to apply the same steps to them. If you are coding along, it is a good place to stop reading and tackle the challenge for yourself.
// Recursive into children
const prevChildren = prev.renderedChildren || [];
const currChildren = currentRenderableVDOMElement.props.children || [];
// We need to loop through all children to compute the diff correctly
// so we pick the length of the element with the most children
const maxLength = Math.max(prevChildren.length, currChildren.length);
for (let index = 0; index < maxLength; index++) {
const currChild = currChildren[index];
if (!currChild) {
// DON'T FORGET
// What does it mean if we don't have a child
// at the current position?
// Replace the error with the appropriate diff
throw new Error(
'We need to handle this case as otherwise the recursion will crash'
);
}
const subTreeDiff = getRenderableVDOMDiff(
currChild,
vdom,
currentRenderableVDOMElement.VDOMPointer
);
diff = [...diff, ...subTreeDiff];
}
return diff;
We iterate through both the previous and current element’s children using the length of the larger of the two arrays. If there is no current child at a given index in the current element’s children array, it indicates that we have reached this index because there was a child at this index in the previous element’s children array. This means that the child that used to exist at this index has been removed.
Therefore, we create a node removed diff with the VDOMPointer of the removed child and skip the rest of this iteration with continue
.
if (!currChild) {
diff.push(createNodeRemoved(prevChildren[index].element.VDOMPointer));
continue;
}
When the whole tree has been processed, we obtain the diff between our previous and new VDOM. We can use it to apply the changes to the DOM atomically and performantly. But, before that pay attention to the output of our log statement in the update
function, and see what our diff object looks like.
Step 2
> If following along, it is now time to go to the branch git checkout chapter-3/step-2
On this new branch, we have set up the starting point (START HERE comment in the code) and a few other small challenges (DON'T FORGET
comments in the code) for patching to work as we described above.
renderedElementsMap
is a lookup table from VDOMPointers
to rendered elements in the DOM, we will be using it for spotting and directly accessing the rendered element in the DOM via VDOMPointer. Also, we are using it here by checking the length of its keys to see whether it’s the first render.
We are now using the computed diff to update the DOM atomically instead of replacing it whole (else block in the following code). As arguments, we are passing our calculated diff
, renderedElementsMap
our lookup table to rendered elements, and renderableVDOM
the VDOM we are rendering DOM elements from.
const createRoot = (rootElement) => ({
rootElement,
render: (rootChild) => {
let renderedElementsMap = {};
// subscribes to DOM changes which are driven by state changes
startRenderSubscription(rootChild, (renderableVDOM, diff) => {
if (Object.keys(renderedElementsMap).length === 0) {
const rootChildAsHTML = renderElementToHtml(
renderableVDOM,
renderedElementsMap
);
rootElement.appendChild(rootChildAsHTML);
} else {
applyDiff(diff, renderedElementsMap, renderableVDOM);
}
});
},
});
The applyDiff
function is placed in the dom-handlers.js
, since its job is to handle DOM updates according to the diff we calculated in Step 1.
The first thing we need to do is sort the operations derived from the difference between the previous and new VDOM states using the order array in the diff module (in the code). The reasoning behind this order is that some of the operations can be destructive in terms of VDOM pointers, as they can influence the order of the elements in the DOM. That’s why we decided to handle node removal first, ensuring that the rendered DOM matches what the diff is working with. Next, we handle node addition since it may change the positions of existing elements. Finally, we handle all the remaining operations:
- Node Removed
- Node Added
- Node Replaced
- Primitive Node Update
- Props Update
After the sorting, we simply apply each kind of update to the DOM with their specialised helper function (in the code).
Let’s dive into the details of each update, and figure out how atomically we can achieve each update, and what kind of pitfalls they have for us 🙂
Node Removed – DOM Applier (in the code)
The goal here is to remove an element and its children from the browser’s DOM using the renderedElementsMap
and the VDOMPointer
of the element to be removed. Additionally, when dealing with components, we need to handle the removal of all the elements they rendered.
We first check if the element to be removed is present in the renderedElementsMap
. If it is, we remove the element from the DOM and delete the corresponding pointers in the renderedElementsMap
.
However, there are cases where the element to be removed is not directly part of the rendered DOM because it is a component. In such cases, we need to find all the elements that the component rendered and delete those. We deal with components in the else part of the code. This part involves finding all the elements rendered by the component, and from them finding their parents and recursively deleting them.
We ensure the renderedElementsMap
accurately reflects the updated state of the DOM after the removal operation.
First, introduce the helper functions that we will use. isChildVDOMPointer
here helps us to figure out if a pointer is a child of another pointer.
export const isChildVDOMPointer = (childVDOMPointer, parentVDOMPointer) => {
// everything is a child of the root level pointer []
if (parentVDOMPointer.length === 0) {
return true;
}
// to find out if a specific pointer is a child of another pointer, we can
// verify whether it contains numbers within the parent pointer
return new RegExp(`^${parentVDOMPointer},(\\d+,?)+`).test(childVDOMPointer);
};
We use findRootVDOMPointers
to create a set of root pointers from the given child pointers.
// Find the root pointers of an array of pointers
export const findRootVDOMPointers = pointers => {
if (pointers.length === 0) {
return pointers;
}
let rootPointers = [pointers[0]];
for (const pointer of pointers.slice(1)) {
const rootPointersOfCurrent = rootPointers.filter(rootPointer =>
isChildVDOMPointer(pointer, rootPointer),
);
if (rootPointersOfCurrent.length === 0) {
const newRootPointers = rootPointers.filter(
rootPointer => !isChildVDOMPointer(rootPointer, pointer),
);
rootPointers = [...newRootPointers, pointer];
}
}
return rootPointers;
};
findRenderedChildrenByVDOMPointer
to find rendered children pertaining to a specific VDOMPointer. It is especially useful to find children of a component, as the component itself is not rendered within the browser’s DOM, making retrieving its children a non-trivial exercise.
const findRenderedChildrenByVDOMPointer = (
renderedElementsMap,
VDOMPointer,
) => {
return Object.entries(renderedElementsMap).filter(([pointer]) =>
isChildVDOMPointer(pointer, VDOMPointer),
);
};
Finally, we combine all these together to apply the changes needed in the applyNodeRemoved
function.
const applyNodeRemoved = (
{ renderedElementsMap },
{ VDOMPointer }
) => {
const elementToRemove = renderedElementsMap[VDOMPointer];
if (elementToRemove) {
elementToRemove.parentNode.removeChild(elementToRemove);
findRenderedChildrenByVDOMPointer(renderedElementsMap, VDOMPointer).forEach(
([pointer]) => {
delete renderedElementsMap[pointer];
}
);
} else {
// for a pointer to a component at 0,1,2
// and rendered children 0,1,2,0 and 0,1,2,1,0
// we want to remove those children
const allChildren = findRenderedChildrenByVDOMPointer(
renderedElementsMap,
VDOMPointer
);
const rootVDOMPointers = findRootVDOMPointers(
allChildren.map(([pointer]) => pointer)
);
rootVDOMPointers.forEach((pointer) => {
applyNodeRemoved({ renderedElementsMap }, { VDOMPointer: pointer });
});
}
delete renderedElementsMap[VDOMPointer];
};
Node Added – DOM Applier (in the code)
In the applyNodeAdded
function, we first need to render our element using our existing rendering functions from Episode 1. If the element output is renderable (it may be a value that doesn’t render to the DOM such as false
), then we insert it in the correct spot.
Hint: We can use the insertBefore
method of the next sibling to place the newly rendered element correctly. And, the following helper function retrieves the insertion point of an element within the browser’s DOM which isn’t trivial, as some elements such as false
or undefined
are part of the renderable VDOM, but not part of the actual DOM).
So, for instance, to render the inner Component [0, 0, 0, 0]
we first find its next sibling’s VDOM Pointer using findNextSiblingOfVDOMPointer
which is VDOMPointer
[0, 0, 0, 1]
in the renderableVDOM
.
/*
Input
- {
renderedElementsMap: a map with keys VDOMPointer and values elements rendered in the browser
renderableVDOM: the renderableVDOM for the current update cycle
}
- VDOMPointer: the pointer to the element for which we want to find the next sibling
- parentPointer: the pointer to the parent of the VDOMPointer and the sibling
Output
- The next sibling of the VDOMPointer: a dom element currently rendered in the browser if found, undefined if the node pointed by the VDOMPointer is the last rendered child of the parent
*/
const findNextSiblingOfVDOMPointer = (
{ renderedElementsMap, renderableVDOM },
VDOMPointer,
parentPointer,
) => {
const parentElementFromRenderableVDOM = findRenderableByVDOMPointer(
renderableVDOM,
parentPointer,
);
const parentChildren = parentElementFromRenderableVDOM.props.children;
const childVDOMIndex = parentChildren.findIndex(
child => child.VDOMPointer === VDOMPointer,
);
let nextSiblingVDOMIndex = childVDOMIndex + 1;
let nextSibling;
// The next sibling could be a value that doesn't render to the DOM such as `false`
// or a sibling that has not yet been rendered to the DOM (if it was added in the same update cycle)
// so we need to continue searching for the first rendered one here
while (
nextSiblingVDOMIndex < parentChildren.length &&
nextSibling === undefined
) {
const nextSiblingFromVDOM =
parentElementFromRenderableVDOM.props.children[nextSiblingVDOMIndex];
if (nextSiblingFromVDOM) {
nextSibling = renderedElementsMap[nextSiblingFromVDOM.VDOMPointer];
}
nextSiblingVDOMIndex += 1;
}
return nextSibling;
};
/*
Input:
- {
renderedElementsMap: a map with keys VDOMPointer and values elements rendered in the browser
renderableVDOM: the renderableVDOM for the current update cycle
}
- VDOMPointer: the pointer to the position at which we want to insert the node
- node: the renderableVDOMElement we want to insert into the DOM
- parentPointer: the pointer to the parent of the VDOMPointer and the sibling
Side effects:
Updates the browser's DOM with the newly added node as well as the reference in the renderedElementsMap.
*/
const applyNodeAdded = (
{ renderedElementsMap, renderableVDOM },
{ VDOMPointer, payload: { node, parentPointer } },
) => {
// DON'T FORGET AFTER YOU HANDLED PROPS UPDATE
// Here we want to render the node from the payload to the DOM
// To do so, you will need to:
// 1. Create the right HTML element for that node
// 2. Find the right place to insert the node (ps: the provided `findNextSiblingOfVDOMPointer` function might come in handy)
// Careful, some primitive elements don't render anything to the DOM
// and don't forget to update the renderedElementsMap after the real DOM is updated.
};
We have all the helpers, and steps to add a new node, if you are coding along, this is a good point to stop reading, and to have a stab at it yourself, and come back once applyNodeAdded
is working properly.
In the solution here, we are creating the HTML element for the given node, finding the appropriate position for insertion using the parent and sibling pointers, and inserting the element into the DOM, and updating the renderedElementsMap
with the new element.
Beware the renderedElementsMap
is updated even if the addedElement
is falsy (e.g., false
) to maintain consistency and ensure that the renderedElementsMap
accurately reflects the state of the rendered elements in the browser. Even though a particular node may not render anything to the DOM (e.g., a primitive element that doesn’t have a visual representation), it is still necessary to update the renderedElementsMap
with the corresponding VDOMPointer
and the associated value (false in this case). This ensures that the renderedElementsMap
remains accurate and consistent with the renderableVDOM
.
const applyNodeAdded = (
{ renderedElementsMap, renderableVDOM },
{ VDOMPointer, payload: { node, parentPointer } }
) => {
const parentElement = renderedElementsMap[parentPointer];
const nextSibling = findNextSiblingOfVDOMPointer(
{ renderedElementsMap, renderableVDOM },
VDOMPointer,
parentPointer
);
const addedElement = renderElementToHtml(node, renderedElementsMap);
// The addedElement could be a value that doesn't render to the DOM such as `false`
if (!addedElement) {
renderedElementsMap[VDOMPointer] = addedElement;
return;
}
parentElement.insertBefore(addedElement, nextSibling);
renderedElementsMap[VDOMPointer] = addedElement;
};
Node Replaced – DOM Applier (in the code)
The node replacement operation is straightforward once we have the applyNodeRemoved
and applyNodeAdded
functionality ready. We can simply call them in order to perform the replacement.
const applyNodeReplaced = (
{ renderedElementsMap, renderableVDOM },
{ VDOMPointer, payload: { newNode, oldNode, parentPointer } }
) => {
applyNodeRemoved(
{ renderedElementsMap, renderableVDOM },
{ VDOMPointer, payload: { parentPointer } }
);
applyNodeAdded(
{ renderedElementsMap, renderableVDOM },
{ VDOMPointer, payload: { node: newNode, parentPointer } }
);
};
Primitive Node Update – DOM Applier (in the code)
We already know that in the case of a primitive node update, the element is persistent, and we are only changing its value. If we want to convert this knowledge into a code block it would be simple as this:
const applyPrimitiveNodeUpdate = (
{ renderedElementsMap },
{ VDOMPointer, payload: { newElement } }
) => {
renderedElementsMap[VDOMPointer].nodeValue = newElement.value;
};
With this, we now can apply the diff items for prop updates, node added, and primitive node updates. In the following section, we will deep dive into how to apply node removed and node replaced diff items.
Properties Update – DOM Applier (in the code)
Updating properties is a bit complex. For standard HTML attributes, we can just add them as we did before (in previous articles), and removing them is as simple as calling the removeAttribute method on the node. On the other hand, you might remember we also handle event listeners, and those are a little trickier. When adding a new event listener, we must first verify there isn’t a pre existing event listener on the node, and if there is one we must remove it. If we weren’t cleaning up previous event listeners, we would keep stale event listeners that would continue firing even though the app developers had updated them.
// Helpers
// map of eventHandlers that the ToDo app requires
const eventHandlersMap = {
onClick: 'click',
// for the `change` event to trigger, the user is required to leave the field and come back
// so it seems like React decided to use the `input` event under the hood
onChange: 'input',
onSubmit: 'submit',
};
const isEventHandlerProp = key => Object.keys(eventHandlersMap).includes(key);
const addEventHandler = (domElement, { key, value }) => {
domElement.addEventListener(eventHandlersMap[key], value);
};
const removeEventHandler = (domElement, { key, value }) => {
domElement.removeEventListener(eventHandlersMap[key], value);
};
const applyPropToHTMLElement = ({ key, value }, element) => {
if (isEventHandlerProp(key)) {
addEventHandler(element, { key, value });
return;
}
if (element[key] !== undefined) {
element[key] = value;
} else {
element.setAttribute(key, value);
}
};
const removePropFromHTMLElement = ({ key, oldValue }, element) => {
if (isEventHandlerProp(key)) {
removeEventHandler(renderedElementsMap[VDOMPointer], { key, oldValue });
return;
}
element.removeAttribute(key);
};
const applyProps = (
{ renderedElementsMap },
{ VDOMPointer, payload: propsChanged }
) => {
// Loop through all prop diff items
Object.entries(propsChanged).forEach(
// Extracting for each the propDiffType (updated or removed)
// As well as the oldValue and the newValue for the given prop
// Example of what each of those values could be:
// [key = 'id', [propdDiffType = 'updated', { oldValue: 'main-title', newValue: 'sub-title' }]]
([key, [propDiffType, { oldValue, newValue }]]) => {
if (propDiffType === propsDiffType.updated) {
if (isEventHandlerProp(key)) {
// DON'T FORGET
// How to handle event listeners?
// What do we need to do when an event listener callback was updated?
}
// START HERE
// How to update the attribute on the element
// Here we are trying to update the attribute `key` of the element at VDOMPointer within the browser's DOM
// from it's old value `oldValue` to its new value `newValue`
} else if (propDiffType === propsDiffType.removed) {
removePropFromHTMLElement(
{ key, oldValue },
renderedElementsMap[VDOMPointer]
);
}
}
);
};
If you are coding along, this is a good place to stop reading, and give it a try to make applyProps
work properly, and be aware of the pitfall of event handlers 🙂
In this final challenge, we begin the solution by applying the property update diff to the element at the specified position. We achieve this by calling applyPropToHTMLElement
with the property key and value.
Upon examining the implementation of applyPropToHTMLElement
, we can observe that if the provided key is an event handler key, we add the value as an event listener using addEventListener
. However, this approach can lead to issues if we fail to clean up previous event listeners because addEventListener
appends additional event listeners instead of replacing existing ones. Hence, in the earlier if block in the code, we must use the old value of the property and remove the existing event listener associated with the given key and value manually.
// ...
if (propDiffType === propsDiffType.updated) {
if (isEventHandlerProp(key)) {
removeEventHandler(renderedElementsMap[VDOMPointer], {
key,
value: oldValue,
});
}
applyPropToHTMLElement(
{ key, value: newValue },
renderedElementsMap[VDOMPointer]
);
}
// ...
And with that last update, we have finalized applying our diff to the DOM. 🌟
Conclusion
Let’s quickly jump back to one of our initial problems. Check if the input field could preserve focus while you are typing, and think about how we resolved a significant UX issue, and made our application performant with the diffing. 🪄
That’s a wrap 🎁 for this fourth article!
Congratulations on following along! 👏
Now our react-like library renders all our components, and is even able to track state so it re-renders after a state changes atomically!
That’s really powerful! ⚛️
Take the chance to play a bit with the TODO app, see it re-render and think about all the parts you’ve just prevented from getting wastefully re-rendered. Pin a little mental ⭐ on you for taking on the journey!
Also, think about 💡what could you do different in the diffing algorithm and application logic to get better performance.
In the next one, we will be focusing on the useEffect hook, hope to see you there!
This article was written as a trio in collaboration with Jean Duthon and Sean Blundell.