It’s been five years since React was introduced to the frontend community. Ever since it’s release, it has opened new avenues to express UI code. With React and it’s associated ecosystem, the community has constantly been working towards solving the shortcomings of client side scripting and one such effort was React Fiber which enabled us application developers to simply declare what our code looks like and how it should behave to changes in data, while behind the scenes it would compute the necessary changes to the UI. And while at it, it would in fact compute them in small time slices instead doing it all at once holding up the JS thread.
Little is know, apparently (https://twitter.com/dan_abramov/status/1009246589473390592), about the package that separates the computation from the scheduling of updates itself — let us write our own custom renderers. In fact, react-reconciler, the package in question, has opened whole new possibilities. Attempts have been made to ensure React code can run in firmata-based hardware, pdfs, QML, Regl, Framer Animations.
Today let’s target the DOM itself and write our own tiny react-dom renderer. If you take it seriously enough* maybe you can replace react-dom with it in you applications and see gains in bundle sizes and perf. Lets dive in.
First off, lets bootstrap a React project using Create React App
npx create-react-app my-react-dom-project
# Or if you use yarnyarn create react-app my-react-dom-project
Let’s modify the root App component to something simpler, for reasons that will become apparent soon.
Run of the mill React code right? If you are not familiar with anything here, it’s recommended that we get our React basics stronger. Also, please ignore the perf concerns for now in the interest of legibility.
Next, create an empty file for our custom dom renderer and call it renderer.js. (Please ignore the name of the gist files)
And import this instead of the react-dom in index.js
Error, right?
So what do we know for sure that we need from our renderer — a render method? A method that takes our React elements, the container DOM node and a callback, right? We hand React our elements, the container node, a callback and React does it magic and runs the callback to signal it has done its work. With react-reconciler
, we can control some of that magic.
Good news is that the official ReactDOM itself uses the react-reconciler package. We can take hints from there.
We find that ReactReconciler (the function exported by the package)
updateContainer
with our elements, the internal form of container and the callback we intended to be run after rendering.So let’s give it a shot.
And we have the following error.
After all our `hostConfig` is an empty object!
Note that all references to “host”, in code and in prose, refer the the environment the React code will be running. It’s not secret that React runs on mobile (Android/iOS) — React just needs a JS environment that can render UI updates. In our project, the host is the DOM itself.
First things first — what is the now
? Internally React needs a way to keep track of time for things like figuring if the piece of computation has ‘expired’ or has overstepped the time allotted. Check out Lin Clark’s talk on React Fiber for a deeper dive into the topic. But then, one wonders “Can’t React use something like Date.now
?” It can! But host environments could provide better ways of tracking time. Like Performance.now
? All host enviroments may not be identical with regard to this. Anyways, let’s use Date.now
for now and move on.
A good way to figure out what goes in the hostConfig
would to look at ReactDOMHostConfig.js (at the time of writing). We’ll find that we need the following.
Simply adding a log statement in each of them and running the app again gives us the following.
**getRootHostContext**
and **getChildHostContext**
This is bit that requires a bit of reading and poking around. Comparing renderers on the official repo, we can safely conclude this: React Reconciler provides getRootHostContext
and getChildHostContext
as ways to share some context among the other config functions. More on this, hopefully, in a follow up post. For now, let’s return empty objects.
**shouldSetTextContent**
Some hosts lets you set text content on the host element, while others may mandate creation of a new element. For now we simply return false.
**createTextInstance**
Let’s log all the arguments received in the function.
Here’s what they are,
0
=> The initial value of this.state.count
<div id="root"></div>
=> The DOM container
{tag: 6, key: null, type: null, stateNode: null, return: FiberNode, …}` => The Fiber associated. We don’t have t worry about this. Most of the time, we just need to pass it on to reconciler. It’s considered to be opaque. In our simple renderer, we don’t even have to do that. React callscreateTextInstance
to create text nodes in the host environment. So just return document.createTextNode(text)
createTextInstance(text,rootContainerInstance,hostContext,internalInstanceHandle) {
return document.createTextNode(text);
}
**createInstance**
Again on logging all the arguments we see
type
=> The type of DOM node. Eg div, span, p etc.<div id="root"></div>
=> The root containerSimilar to createTextNode
, let’s just return a div node i.e. => document.createElement(type)
createInstance(type,props,rootContainerInstance,hostContext,internalInstanceHandle) {return document.createElement(type);},
**appendInitialChild**
We see the parent DOM node, with no attributes or properties set, and its respective child node. It only makes sense to append the child to parent using our host’s api, in our case .appendChild()
appendInitialChild(parentInstance, child) {parentInstance.appendChild(child)}
**finalizeInitialChildren**
It’s arguments
domElement
: <div></div>
from <div>{this.state.count}</div>
in our App.jstype
: divprops
{ children: 0 }
from {this.state.count}
and it’s initial value 0rootContainerInstance
hostContext
If we take a closer look at the logs, we find that shouldSetTextContent
=> createTextInstance
=> createInstance
=> finalizeInitialChildren
happens for every element declared in App.js —
The reconciler is trying to create nodes in the host enviroment, in our case the DOM!
**finalizeInitializeChildren**
This receives a DOM node, type, props and the root container in the host. If we take hint from the name of this function (and of course consult other reconcilers :P ), we the sub trees created for us by React before it gets pushed to the DOM. For example,
This represents<div>{this.state.count}</div>
And this, <button onClick={onClickHandler}>Increment</button
Notice how there are no classes on the elements. finalizeInitialChildren
can be used to apply classes, event handlers and other attributes and properties. Since, in our simple app, all we really need are classes (html classes) and event handlers, lets apply them. If you wanted to handle other attributes and properties, this would be the place.
prepareForCommit
and resetAfterCommit
Let leave these a noop for now. But otherwise, if you think before the reconciler enters the commit phase, you need to get anything done, do it in prepareForCommit. Likewise, the clean thereafter can be done in resetAfterCommit
appendChildToContainer
As the name suggest, just append our prepared DOM tree.
appendChildToContainer(container, child) { container.appendChild(child)},
Voila!
Our first render using react-reconciler
API!
Click on Increment a couple of times.
UI doesn’t update. A few methods in the Reconciler config get called.
We have already seen resetAfterCommit. Its prepareUpdate
and commitTextUpdate
we are after. Although, we don’t see commitUpdate
it’s best we look at it now because it works very closely with prepareUpdate
like commitTextUpdate.
Remember how React initially took the world by storm with this thing called the “Diffing Algorithm”. Well, we can write it ourselves now!
Before we do that, let’s keep in mind — React, the core library, is now just a UI updates scheduler. It’s we, as custom renderer authors, that decide that diffing actually means. For DOM, we look for Element properties and attributes. For a different host, it could be anything. Let’s play around a bit.
Since we define the reconciliation (the diff’ing), we also get to choose what data structure holds the diff’ed changes, and it is this data structure that gets passed around — returned by us from prepareUpdate, passed on to commitUpdate
. commitTextUpdate
is quite simple — there’s no need for any data structure passing around. All we really need is the old text and the new updated and see if how the difference needs to be handled. In most simple cases, you could simply assign the new text to the DOM element.
commitTextUpdate(textInstance, oldText, newText) { textInstance.nodeValue = newText;},
Let’s get our app to work for now (especially for those of us who are looking for a sense of accomplishment) and revisit commitUpdate
and prepareUpdate
later.
Let’s get back to prepareUpdate
and commitUpdate
.
We’ll need to modify our App a bit — it’s too simple currently to trigger prepareUpdate
and commitUpdate.
Let’s say we want to display the counter in red once it exceeds a threshold of say 10. Our App.js must be updated with the following line
<div className={ this.state.count > 5 ? "counter red": "counter" }>{this.state.count}</div>
And lets assume out css file (App.css) has some styles to change the color of the text.
.red {color: red;}
Save, reload and start incrementing the counter.
Notice how prepareUpdate
and commitUpdate
get called three times each. That’s one each for the div.root
, inner div
and the button. You can quickly verify that by temporarily adding a random markup like <p><span>some text</span></p>
Let’s try to diff our DOM! Let’s study all its arguments.
prepareUpdate(domElement,type,oldProps,newProps,rootContainerInstance,hostContext) {console.log('prepareUpdate', oldProps, newProps, rootContainerInstance);return [ null ];},
prepareUpdate being run for each react element on incrementing
As increment was clicked, children updated from 0 to 1. Clicking on Increment for > 5 times we see,
At the 6th click, className changed from “counter” to “counter red”. Another prop changed.
In an ideal world, if DOM data structures in JS land had simple direct mapping to C++ data structures beneath the hood, we could blindly go and run a update operation on each of these props. But we work within constraints! DOM is bound to marshal structures back and forth. . So let’s compute the least amount of necessary updates we need to make to the DOM.
And apply those updates in commitUpdate
Note that we are handling event listeners (which are also passed as props) differently. And for simplicity we ensure only one event listener is present at all times.
And there we go!
The complete renderer.js
Hope you enjoyed this article and will continue tinkering around react-reconciler!
Github repo: https://github.com/prometheansacrifice/my-react-dom