Hello World Custom React Renderer

In the previous post, we saw some introduction to custom react renderers. Let’s look at building a hello world custom react renderer. We are going to start backwards from a react app and fill out the missing pieces for our implementation. We are going to create a super small very basic DOM renderer. It goes no where near realising all the performance optimisations and the implementation is most probably not the 100% correct way to do it.

  1. Create a new react project using create-react-app and start it.
create-react-app hello-react-custom-renderer
cd hello-react-custom-renderer
yarn start

You should have your app running at http://localhost:3000/

2. Let us add a simple counter. Modify the App.js file to as shown below. This counter example will serve as a test for renderer implementation.

Counter with default ReactDOM renderer

2. Let us replace ReactDOM usage with our custom renderer in the index.js file.

import React from 'react';
// import ReactDOM from 'react-dom';
import MyCustomRenderer from './myCustomRenderer';
import './index.css';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
// ReactDOM.render(<App />, document.getElementById('root'));
MyCustomRenderer.render(<App />, document.getElementById('root'));
registerServiceWorker();

3. Add react-reconciler package to the project. yarn add react-reconciler

Create a new file named myCustomRenderer.js with code as shown below. render function is the only public interface that renderers need to implement. Inside this, we use the react-reconciler.

In our index.js, we use the render function as

MyCustomRenderer.render(<App />, document.getElementById('root'));

This render function takes 3 arguments, reactElement in our case it is our App react element. domElement is the DOM element which has the id root into which reactElement will be rendered. The 3rd argument callback is optional that is called after the component App is rendered or updated. In our case we omit that.

If you are confused with termsComponent , Element , https://reactjs.org/blog/2015/12/18/react-components-elements-and-instances.html should be help clarifying a bit.

hostConfig is the object where we need to implement the functions that will be used by ReactReconciler. There is no documentation or guide specifically for ReactReconciler other than looking at source code and other implementations.

At this point, we will thrown with the following error.

now is a function ReactReconciler expects in hostConfig. This function seems to be used in react reconciler for scheduling updates to the target. We will just return the javascript Date.now for now.

const hostConfig = {
now: Date.now
};

At this point, we should be thrown with the following error,

getRootHostContext is another function that react-reconciler expects in hostConfig. The intent for this functions seems to be for maintaining some information if needed by the renderer implementation. We are going to provide an empty function for now.

const hostConfig = {
now: Date.now,
getRootHostContext: () => {}
};

After repeating this reverse engineering for few more steps, and adding empty stubs,hostConfig looks like this

const hostConfig = {
now: Date.now,
getRootHostContext: () => {},
prepareForCommit: () => {},
resetAfterCommit: () => {},
getChildHostContext: () => {},
shouldSetTextContent: () => {},
createInstance: () => {},
createTextInstance: () => {},
appendInitialChild: () => {},
finalizeInitialChildren: () => {},
};

At this point, all the errors seem to have gone, but nothing renders on the screen. Looking at different implementation of renderers , it turns out we need set a property called supportsMutation to true. [Update 1: This indicates the react-reconciler that the target(ie.DOM) UI API supports mutation of the UI tree Ex: operations like appendChild, removeChild etc ]

const hostConfig = {
now: Date.now,
getRootHostContext: () => {},
prepareForCommit: () => {},
resetAfterCommit: () => {},
getChildHostContext: () => {},
shouldSetTextContent: () => {},
createInstance: () => {},
createTextInstance: () => {},
appendInitialChild: () => {},
finalizeInitialChildren: () => {},
supportsMutation: true
};

After we add this function, we get the following error

Let us add appendChildToContainer to hostConfig. After this the errors seems to have gone but we are back to empty screen. I added some console log to figure out what functions are being called. It looks like this

myCustomRenderer.js:7 getRootHostContext
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:22 createInstance
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:25 createTextInstance
myCustomRenderer.js:22 createInstance
myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:22 createInstance
2myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:25 createTextInstance
myCustomRenderer.js:22 createInstance
myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
3myCustomRenderer.js:25 createTextInstance
myCustomRenderer.js:22 createInstance
3myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:16 getChildHostContext
myCustomRenderer.js:19 shouldSetTextContent
myCustomRenderer.js:25 createTextInstance
myCustomRenderer.js:22 createInstance
myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:22 createInstance
3myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:22 createInstance
myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:22 createInstance
2myCustomRenderer.js:28 appendInitialChild
myCustomRenderer.js:31 finalizeInitialChildren
myCustomRenderer.js:10 prepareForCommit
myCustomRenderer.js:35 appendChildToContainer
myCustomRenderer.js:13 resetAfterCommit

Let us try to replace the empty function stubs by what react-reconciler expects to be.

The functions can be grouped into different categories

Creation operations

  1. createInstance(type, newProps, rootContainerInstance, _currentHostContext, workInProgress): This is where react-reconciler wants to create an instance of UI element in terms of the target. Since our target here is the DOM, we will create document.createElement and type is the argument that contains the type string like div or img or h1 etc. [Update 1: The initial values of domElement attributes can be set in this function from the newProps argument ]
  2. createTextInstance: This function is used to create separate text nodes if the target allows only creating text in separate text nodes.

UI tree operations

  1. appendInitialChild: maps to domElement.appendChild. This function gets called for initial UI tree creation.
  2. appendChild: maps to domElement.appendChild. Similar to appendInitialChild but for subsequent tree manipulation.
  3. removeChild: maps to domElement.removeChild.
  4. appendChildToContainer: maps to domElement.appendChild. Gets called in the commitPhase of react-reconciler

Update prop operations

  1. finalizeInitialChildren: This function can be left empty.
  2. prepareUpdate: This is where we would want to diff between oldProps and newProps and decide whether to update or not. In our implementation, we just set it to true for simplicity.
  3. commitUpdate(domElement, updatePayload, type, oldProps, newProps) : This function is used to subsequently update domElement attributes from the newProps values.
Counter with our custom renderer

That’s it . We have a super small tiny hello world react renderer working. You can find source code of the repo at https://github.com/agenthunt/hello-react-custom-renderer

[Update 11/06/2018]: Updated based on some clarifications received from Dan Abramov. Thanks Dan.