paint-brush
Learn how React Reconciler package works by building your own lightweight React DOMby@prometheansacrifice
8,108 reads
8,108 reads

Learn how React Reconciler package works by building your own lightweight React DOM

by Manas JayanthJune 22nd, 2018
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

It’s been five years since <a href="https://hackernoon.com/tagged/react" target="_blank">React</a> 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.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Learn how React Reconciler package works by building your own lightweight React DOM
Manas Jayanth HackerNoon profile picture

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)

  1. Needs a config.
  2. Needs a internal container data structure to render into
  3. Run 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

  1. type => The type of DOM node. Eg div, span, p etc.
  2. props => Props passed. Since we never passed anything to the div in our app, only children are passed.
  3. <div id="root"></div> => The root container
  4. {…} => The fiber node

Similar 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

  1. domElement : <div></div> from <div>{this.state.count}</div> in our App.js
  2. type : div
  3. props { children: 0 } from {this.state.count} and it’s initial value 0
  4. rootContainerInstance
  5. 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 —

  1. <div>{this.state.count}</div>
  2. <button onClick={onClickHandler}>Increment</button>
  3. Parent div: <div> … </div>

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 prepareUpdateand 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