Exploring React Reconciler API
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.
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.
While at it, it would in fact execute them in small time slices instead doing it all at once holding up the JS thread.
Little is known, apparently, about the package that separates the computation from the scheduling of updates itself and can help 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 yarn
yarn create react-app my-react-dom-project
Let’s modify the root App component to something simpler, for reasons that will become apparent soon.
import React, { Component } from 'react';
class App extends Component {
constructor() {
super();
this.state = {
count: 0
};
}
render() {
const onClickHandler = () => {
this.setState(state => {
return {
count: state.count + 1
};
});
};
return (
<div>
<div>{this.state.count}</div>
<button onClick={onClickHandler}>Increment</button>
</div>
);
}
}
export default App;
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
export default {}
And import our renderer.js instead of the react-dom in index.js
import React from 'react';
import ReactDOM from './renderer.js';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
ReactDOM.render(<App />, document.getElementById('root'));
registerServiceWorker();
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)
- Needs a config.
- Needs a internal container data structure to render into
- 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.
import ReactReconciler from 'react-reconciler';
const hostConfig = {};
const DOMRenderer = ReactReconciler(hostConfig);
let internalContainerStructure;
export default {
render(elements, containerNode, callback) {
// We must do this only once
if (!internalContainerStructure) {
internalContainerStructure = DOMRenderer.createContainer(
containerNode,
false,
false
);
}
DOMRenderer.updateContainer(elements, internalContainerStructure, null, callback);
}
}
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 to 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.
const hostConfig = {
getRootHostContext(rootContainerInstance) {
},
getChildHostContext(parentHostContext, type, rootContainerInstance) {
},
getPublicInstance(instance) {
},
prepareForCommit(containerInfo) {
},
resetAfterCommit(containerInfo) {
},
createInstance(
type,
props,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
},
appendInitialChild(parentInstance, child) {
},
finalizeInitialChildren(
domElement,
type,
props,
rootContainerInstance,
hostContext
) {
},
prepareUpdate(
domElement,
type,
oldProps,
newProps,
rootContainerInstance,
hostContext
) {
},
shouldSetTextContent(type, props) {
},
shouldDeprioritizeSubtree(type, props) {
},
createTextInstance(
text,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
},
now: null,
isPrimaryRenderer: true,
scheduleDeferredCallback: "",
cancelDeferredCallback: "",
// -------------------
// Mutation
// -------------------
supportsMutation: true,
commitMount(domElement, type, newProps, internalInstanceHandle) {
},
commitUpdate(
domElement,
updatePayload,
type,
oldProps,
newProps,
internalInstanceHandle
) {
},
resetTextContent(domElement) {
},
commitTextUpdate(textInstance, oldText, newText) {
},
appendChild(parentInstance, child) {
},
appendChildToContainer(container, child) {
},
insertBefore(parentInstance, child, beforeChild) {
},
insertInContainerBefore(container, child, beforeChild) {
},
removeChild(parentInstance, child) {
},
removeChildFromContainer(container, child) {
}
};
import ReactReconciler from 'react-reconciler';
const hostConfig = {
getRootHostContext(rootContainerInstance) {
return {}
},
getChildHostContext(parentHostContext, type, rootContainerInstance) {
return {};
},
getPublicInstance(instance) {
console.log('getPublicInstance');
},
prepareForCommit(containerInfo) {
},
resetAfterCommit(containerInfo) {
},
createInstance(
type,
props,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
return document.createElement(type);
},
appendInitialChild(parentInstance, child) {
parentInstance.appendChild(child)
},
finalizeInitialChildren(
domElement,
type,
props,
rootContainerInstance,
hostContext
) {
const { children, ...otherProps } = props;
Object.keys(otherProps).forEach(attr => {
if (attr === 'className') {
domElement.class = otherProps[attr];
} else if (attr === 'onClick') {
const listener = otherProps[attr];
if (domElement.__ourVeryHackCacheOfEventListeners) {
domElement.__ourVeryHackCacheOfEventListeners.push(listener)
} else {
domElement.__ourVeryHackCacheOfEventListeners = [ listener ]
}
domElement.addEventListener('click', listener);
} else {
throw new Error('TODO: We haven\'t handled other properties/attributes')
}
})
},
prepareUpdate(
domElement,
type,
oldProps,
newProps,
rootContainerInstance,
hostContext
) {
console.log('prepareUpdate');
return [ null ];
},
shouldSetTextContent(type, props) {
return false;
},
shouldDeprioritizeSubtree(type, props) {
console.log('shouldDeprioritizeSubtree');
},
createTextInstance(
text,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
return document.createTextNode(text);
},
now: Date.now,
isPrimaryRenderer: true,
scheduleDeferredCallback: "",
cancelDeferredCallback: "",
// -------------------
// Mutation
// -------------------
supportsMutation: true,
commitMount(domElement, type, newProps, internalInstanceHandle) {
console.log('commitMount');
},
commitUpdate(
domElement,
updatePayload,
type,
oldProps,
newProps,
internalInstanceHandle
) {
},
resetTextContent(domElement) {
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.nodeValue = newText;
},
appendChild(parentInstance, child) {
},
appendChildToContainer(container, child) {
container.appendChild(child)
},
insertBefore(parentInstance, child, beforeChild) {
console.log('insertBefore');
},
insertInContainerBefore(container, child, beforeChild) {
console.log('insertInContainerBefore');
},
removeChild(parentInstance, child) {
console.log('removeChild');
},
removeChildFromContainer(container, child) {
console.log('removeChildFromContainer');
}
};
const DOMRenderer = ReactReconciler(hostConfig);
let internalContainerStructure;
export default {
render(elements, containerNode, callback) {
// We must do this only once
if (!internalContainerStructure) {
internalContainerStructure = DOMRenderer.createContainer(
containerNode,
false,
false
);
}
DOMRenderer.updateContainer(elements, internalContainerStructure, null, callback);
}
}
Simply adding a log statement in each of them and running the app again gives us the following.
getRootHostContext and getChildHostContext
This bit 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 calls createTextInstance
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.
props => Props passed. Since we never passed anything to the div in our app, only children are passed.
<div id="root"></div> => The root container
{…} => 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
- domElement : <div></div> from <div>{this.state.count}</div> in our App.js
- type : div
- props { children: 0 } from {this.state.count} and it’s initial value 0
- rootContainerInstance
- 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 —
<div>{this.state.count}</div>
<button onClick={onClickHandler}>Increment</button>
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 ), 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.
finalizeInitialChildren(
domElement,
type,
props,
rootContainerInstance,
hostContext
) {
const { children, ...otherProps } = props;
Object.keys(otherProps).forEach(attr => {
if (attr === 'className') {
domElement.class = otherProps[attr];
} else if (attr === 'onClick') {
const listener = otherProps[attr];
if (domElement.__ourVeryHackCacheOfEventListeners) {
domElement.__ourVeryHackCacheOfEventListeners.push(listener)
} else {
domElement.__ourVeryHackCacheOfEventListeners = [ listener ]
}
domElement.addEventListener('click', listener);
} else {
throw new Error('TODO: We haven\'t handled other properties/attributes')
}
})
},
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.
import ReactReconciler from 'react-reconciler';
const hostConfig = {
getRootHostContext(rootContainerInstance) {
return {}
},
getChildHostContext(parentHostContext, type, rootContainerInstance) {
return {};
},
getPublicInstance(instance) {
console.log('getPublicInstance');
},
prepareForCommit(containerInfo) {
},
resetAfterCommit(containerInfo) {
},
createInstance(
type,
props,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
return document.createElement(type);
},
appendInitialChild(parentInstance, child) {
parentInstance.appendChild(child)
},
finalizeInitialChildren(
domElement,
type,
props,
rootContainerInstance,
hostContext
) {
const { children, ...otherProps } = props;
Object.keys(otherProps).forEach(attr => {
if (attr === 'className') {
domElement.class = otherProps[attr];
} else if (attr === 'onClick') {
const listener = otherProps[attr];
if (domElement.__ourVeryHackCacheOfEventListeners) {
domElement.__ourVeryHackCacheOfEventListeners.push(listener)
} else {
domElement.__ourVeryHackCacheOfEventListeners = [ listener ]
}
domElement.addEventListener('click', listener);
} else {
throw new Error('TODO: We haven\'t handled other properties/attributes')
}
})
},
prepareUpdate(
domElement,
type,
oldProps,
newProps,
rootContainerInstance,
hostContext
) {
console.log('prepareUpdate');
return [ null ];
},
shouldSetTextContent(type, props) {
return false;
},
shouldDeprioritizeSubtree(type, props) {
console.log('shouldDeprioritizeSubtree');
},
createTextInstance(
text,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
return document.createTextNode(text);
},
now: Date.now,
isPrimaryRenderer: true,
scheduleDeferredCallback: "",
cancelDeferredCallback: "",
// -------------------
// Mutation
// -------------------
supportsMutation: true,
commitMount(domElement, type, newProps, internalInstanceHandle) {
console.log('commitMount');
},
commitUpdate(
domElement,
updatePayload,
type,
oldProps,
newProps,
internalInstanceHandle
) {
},
resetTextContent(domElement) {
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.nodeValue = newText;
},
appendChild(parentInstance, child) {
},
appendChildToContainer(container, child) {
container.appendChild(child)
},
insertBefore(parentInstance, child, beforeChild) {
console.log('insertBefore');
},
insertInContainerBefore(container, child, beforeChild) {
console.log('insertInContainerBefore');
},
removeChild(parentInstance, child) {
console.log('removeChild');
},
removeChildFromContainer(container, child) {
console.log('removeChildFromContainer');
}
};
const DOMRenderer = ReactReconciler(hostConfig);
let internalContainerStructure;
export default {
render(elements, containerNode, callback) {
// We must do this only once
if (!internalContainerStructure) {
internalContainerStructure = DOMRenderer.createContainer(
containerNode,
false,
false
);
}
DOMRenderer.updateContainer(elements, internalContainerStructure, null, callback);
}
}
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>
import React, { Component } from 'react';
class App extends Component {
constructor() {
super();
this.state = {
count: 0
};
}
render() {
const onClickHandler = () => {
this.setState(state => {
return {
count: state.count + 1
};
});
};
return (
<div className="root">
{ /* Add <p><span>some text</span></p> temporarily and see prepareUpdate and commitUpdate get called 5 times instead of 3 */ }
<div className="counter">{this.state.count}</div>
<button onClick={onClickHandler}>Increment</button>
</div>
);
}
}
export default App;
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 ];
},
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. The diff’ing is a necessary hack and not a feature for writing functional declarative UI components. So let’s compute the least amount of necessary updates we need to make to the DOM.
prepareUpdate(
domElement,
type,
oldProps,
newProps,
rootContainerInstance,
hostContext
) {
const propKeys = new Set(
Object.keys(newProps).concat(
Object.keys(oldProps)
)
).values();
const payload = [];
for (let key of propKeys) {
if (
key !== 'children' && // text children are already handled
oldProps[key] !== newProps[key]
) {
payload.push({ [key]: newProps[key] })
}
}
return payload;
},
And apply those updates in commitUpdate
commitUpdate(
domElement,
updatePayload,
type,
oldProps,
newProps,
internalInstanceHandle
) {
updatePayload.forEach(update => {
Object.keys(update).forEach(key => {
if (key === 'onClick') {
domElement.__ourVeryHackyCacheOfEventListeners.forEach(listener => { // To prevent leak
domElement.removeEventListener('click', listener)
})
domElement.__ourVeryHackyCacheOfEventListeners = [ update[key] ];
domElement.addEventListener('click', update[key])
} else {
domElement[key] = update[key];
}
})
})
},
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
import ReactReconciler from 'react-reconciler';
const hostConfig = {
getRootHostContext(rootContainerInstance) {
return {}
},
getChildHostContext(parentHostContext, type, rootContainerInstance) {
return {};
},
getPublicInstance(instance) {
console.log('getPublicInstance');
},
prepareForCommit(containerInfo) {
// console.log('prepareForCommit');
},
resetAfterCommit(containerInfo) {
// console.log('resetAfterCommit');
},
createInstance(
type,
props,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
// console.log(
// 'createInstance',
// type,
// props,
// rootContainerInstance,
// hostContext,
// );
return document.createElement(type);
},
appendInitialChild(parentInstance, child) {
parentInstance.appendChild(child)
},
finalizeInitialChildren(
domElement,
type,
props,
rootContainerInstance,
hostContext
) {
const { children, ...otherProps } = props;
Object.keys(otherProps).forEach(attr => {
if (attr === 'className') {
domElement.className = otherProps[attr];
} else if (attr === 'onClick') {
const listener = otherProps[attr];
if (domElement.__ourVeryHackyCacheOfEventListeners) {
domElement.__ourVeryHackyCacheOfEventListeners.push(listener)
} else {
domElement.__ourVeryHackyCacheOfEventListeners = [ listener ]
}
domElement.addEventListener('click', listener);
} else {
throw new Error('TODO: We haven\'t handled other properties/attributes')
}
})
// console.log('finalizeInitialChildren', domElement, type, props, rootContainerInstance, hostContext);
},
prepareUpdate(
domElement,
type,
oldProps,
newProps,
rootContainerInstance,
hostContext
) {
const propKeys = new Set(
Object.keys(newProps).concat(
Object.keys(oldProps)
)
).values();
const payload = [];
for (let key of propKeys) {
if (
key !== 'children' && // text children are already handled
oldProps[key] !== newProps[key]
) {
payload.push({ [key]: newProps[key] })
}
}
return payload;
},
shouldSetTextContent(type, props) {
return false; // || true;
},
shouldDeprioritizeSubtree(type, props) {
console.log('shouldDeprioritizeSubtree');
},
createTextInstance(
text,
rootContainerInstance,
hostContext,
internalInstanceHandle
) {
// console.log(
// 'createTextInstance',
// text,
// rootContainerInstance,
// hostContext,
// );
return document.createTextNode(text);
},
now: Date.now,
isPrimaryRenderer: true,
scheduleDeferredCallback: "",
cancelDeferredCallback: "",
// -------------------
// Mutation
// -------------------
supportsMutation: true,
commitMount(domElement, type, newProps, internalInstanceHandle) {
console.log('commitMount');
},
commitUpdate(
domElement,
updatePayload,
type,
oldProps,
newProps,
internalInstanceHandle
) {
updatePayload.forEach(update => {
Object.keys(update).forEach(key => {
if (key === 'onClick') {
domElement.__ourVeryHackyCacheOfEventListeners.forEach(listener => { // To prevent leak
domElement.removeEventListener('click', listener)
})
domElement.__ourVeryHackyCacheOfEventListeners = [ update[key] ];
domElement.addEventListener('click', update[key])
} else {
domElement[key] = update[key];
}
})
})
},
resetTextContent(domElement) {
console.log('resetTextContent');
},
commitTextUpdate(textInstance, oldText, newText) {
// console.log('commitTextUpdate', oldText, newText);
textInstance.nodeValue = newText;
},
appendChild(parentInstance, child) {
console.log('appendChild');
},
appendChildToContainer(container, child) {
// console.log('appendChildToContainer', container, child);
container.appendChild(child)
},
insertBefore(parentInstance, child, beforeChild) {
console.log('insertBefore');
},
insertInContainerBefore(container, child, beforeChild) {
console.log('insertInContainerBefore');
},
removeChild(parentInstance, child) {
console.log('removeChild');
},
removeChildFromContainer(container, child) {
console.log('removeChildFromContainer');
}
};
const DOMRenderer = ReactReconciler(hostConfig);
let internalContainerStructure;
export default {
render(elements, containerNode, callback) {
// We must do this only once
if (!internalContainerStructure) {
internalContainerStructure = DOMRenderer.createContainer(
containerNode,
false,
false
);
}
DOMRenderer.updateContainer(elements, internalContainerStructure, null, callback);
}
}
Hope you enjoyed this article and will continue tinkering with react-reconciler!
GitHub repo: https://github.com/prometheansacrifice/my-react-dom
Super article. I wish there were more about the internals of React. Using React is good, but understanding how it works under-the-hood is top notch developer knoweldges. Thanks for sharing! 😉
I’m glad you liked it :) I’m already working on a post about how fibers work inside React Reconciler and how components are turned into DOM side effects :)