atulr.com/blog
atulr.comtwittergithublinkedin

⚛️✌️ Part 2/3 - Beginners guide to Custom React Renderers. How to build your own renderer from scratch?

October 22, 2018

This is the continuation of the post here: ⚛️👆 Part 1/3 - Beginners guide to Custom React Renderers. How to build your own renderer from scratch?. I would strongly recommend reading Part 1 before. This part will cover the initial render phase of the renderer.


HostConfig

Recap: Renderers are required to implement all the necessary platform specfic functions inside the HostConfig. Our HostConfig looks like this at the moment.

const HostConfig = {
  //TODO We will specify all required methods here
}

From the source code of react-reconciler, we find the complete list of methods in hostConfig as follows:

HostConfig.getPublicInstance
HostConfig.getRootHostContext
HostConfig.getChildHostContext
HostConfig.prepareForCommit
HostConfig.resetAfterCommit
HostConfig.createInstance
HostConfig.appendInitialChild
HostConfig.finalizeInitialChildren
HostConfig.prepareUpdate
HostConfig.shouldSetTextContent
HostConfig.shouldDeprioritizeSubtree
HostConfig.createTextInstance
HostConfig.scheduleDeferredCallback
HostConfig.cancelDeferredCallback
HostConfig.setTimeout
HostConfig.clearTimeout
HostConfig.noTimeout
HostConfig.now
HostConfig.isPrimaryRenderer
HostConfig.supportsMutation
HostConfig.supportsPersistence
HostConfig.supportsHydration
// -------------------
//      Mutation
//     (optional)
// -------------------
HostConfig.appendChild
HostConfig.appendChildToContainer
HostConfig.commitTextUpdate
HostConfig.commitMount
HostConfig.commitUpdate
HostConfig.insertBefore
HostConfig.insertInContainerBefore
HostConfig.removeChild
HostConfig.removeChildFromContainer
HostConfig.resetTextContent
HostConfig.hideInstance
HostConfig.hideTextInstance
HostConfig.unhideInstance
HostConfig.unhideTextInstance
// -------------------
//     Persistence
//     (optional)
// -------------------
HostConfig.cloneInstance
HostConfig.createContainerChildSet
HostConfig.appendChildToContainerChildSet
HostConfig.finalizeContainerChildren
HostConfig.replaceContainerChildren
HostConfig.cloneHiddenInstance
HostConfig.cloneUnhiddenInstance
HostConfig.createHiddenTextInstance
// -------------------
//     Hydration
//     (optional)
// -------------------
HostConfig.canHydrateInstance
HostConfig.canHydrateTextInstance
HostConfig.getNextHydratableSibling
HostConfig.getFirstHydratableChild
HostConfig.hydrateInstance
HostConfig.hydrateTextInstance
HostConfig.didNotMatchHydratedContainerTextInstance
HostConfig.didNotMatchHydratedTextInstance
HostConfig.didNotHydrateContainerInstance
HostConfig.didNotHydrateInstance
HostConfig.didNotFindHydratableContainerInstance
HostConfig.didNotFindHydratableContainerTextInstance
HostConfig.didNotFindHydratableInstance
HostConfig.didNotFindHydratableTextInstance

🤬Now, that looks like too much work 🤯. Fortunately, we only need to implement few of those functions to get the renderer up and running.🕺🏻👻
shocked meme

I will try to explain what most of these methods do, but we will primarily focus on the ones we need. Disclaimer: Most of the content is a result of my experimentation with React renderers and reading through the publicly available source code and blog posts. Hence, if you find anything that needs correction, please do let me know in the comments.

Reconciler calls different functions from host config on the initial render phase as compared to whenever an update occurs via setState. Lets focus on initial render first.

Initial render

We are trying to render src/index.js that looks like this:

const Text = (props) => {
  return <p className={props.className}>{props.content}</p>
}

const App = () => {
  return (
    <div>
      <Text className="hello-class" content="Hello" />
      <span style="color:blue;">World</span>
    </div>
  )
}

So our rendered view tree should look like this:

Now lets look at the error that we got earlier: now error
From the list of functions in the hostConfig lets implement now().

const HostConfig = {
  now: Date.now,
}

now is used by the reconciler to calculate the current time. Hence we will provide it Date.now.

Refresh! and we get : get root host error Lets stub this function

const HostConfig = {
  now: Date.now,
  getRootHostContext: function (...args) {
    console.log('getRootHostContext', ...args)
  },
}

Refresh! and we get: get child host error

Continuing the chain till we have no more errors we get:

const HostConfig = {
  now: Date.now,
  getRootHostContext: function (...args) {
    console.log('getRootHostContext', ...args)
  },
  getChildHostContext: function (...args) {
    console.log('getChildHostContext', ...args)
  },
  shouldSetTextContent: function (...args) {
    console.log('shouldSetTextContent', ...args)
  },
  createTextInstance: function (...args) {
    console.log('createTextInstance', ...args)
  },
  createInstance: function (...args) {
    console.log('createInstance', ...args)
  },
  appendInitialChild: function (...args) {
    console.log('appendInitialChild', ...args)
  },
  finalizeInitialChildren: function (...args) {
    console.log('finalizeInitialChildren', ...args)
  },
  prepareForCommit: function (...args) {
    console.log('prepareForCommit', ...args)
  },
  resetAfterCommit: function (...args) {
    console.log('resetAfterCommit', ...args)
  },
}

Now we should get a blank screen, but our logs should help us figure out what the reconciler is trying to do and in what order these functions are getting called. initial render logs list (Right Click on the image and select Open Image in New Tab to get a better resolution version).

The order of execution looks like this:

inital render flow

Now we should be able to guess what these methods do. But instead of just making wild guesses, It is a good idea to read through the source code of react-dom to better understand what each of these functions are doing.

▸ now


This function is used by the reconciler in order to calculate current time for prioritising work. In case of react-dom, it uses performace.now if available or it falls back to Date.now Hence, lets just keep it as Date.now for our custom renderer.

▸ getRootHostContext


The function signature is:

function getRootHostContext(nextRootInstance) {
  let context = {
    // This can contain any data that you want to pass down to immediate child
  }
  return context
}

Parameters

  • nextRootInstance: nextRootInstance is basically the root dom node you specify while calling render. This is most commonly <div id="root"></div>

Return Value A context object that you wish to pass to immediate child.

Purpose

This function lets you share some context with the other functions in this HostConfig.

For our custom renderer

Hence in our case lets just return a blank object as show above.

getRootHostContext: function (nextRootInstance) {
  let rootContext = {}
  return rootContext
}

▸ getChildHostContext


The function signature is:

function getChildHostContext(parentContext, fiberType, rootInstance) {
  let context = {
    // This can contain any data that you want to pass down to immediate child
  }
  return context
}

Parameters

  • parentContext: Context from parent. Example: This will contain rootContext for the immediate child of roothost.
  • rootInstance: rootInstance is basically the root dom node you specify while calling render. This is most commonly <div id="root"></div>
  • fiberType: This contains the type of fiber i.e, ‘div’, ‘span’, ‘p’, ‘input’ etc.

Return Value A context object that you wish to pass to immediate child.

Purpose

This function provides a way to access context from the parent and also a way to pass some context to the immediate children of the current node. Context is basically a regular object containing some information.

For our custom renderer

Hence in our case lets just return a blank object as show above.

getChildHostContext: function (parentContext, fiberType, rootInstance) {
  let context = {}
  return context
}

▸ shouldSetTextContent


The function signature is:

function shouldSetTextContent(type, nextProps) {
  return Boolean
}

Parameters

  • nextProps: Contains the props passed to the host react element.
  • type: This contains the type of fiber i.e, ‘div’, ‘span’, ‘p’, ‘input’ etc.

Return Value This should be a boolean value.

Purpose

If the function returns true, the text would be created inside the host element and no new text element would be created separately.

If this returned true, the next call would be to createInstance for the current element and traversal would stop at this node (children of this element wont be traversed).

If it returns false, getChildHostContext and shouldSetTextContent will be called on the child elements and it will continue till shouldSetTextContent returns true or if the recursion reaches the last tree endpoint which usually is a text node. When it reaches the last leaf text node it will call createTextInstance

In case of react-dom the implementation is as follows:

return type === 'textarea' ||
type === 'option' ||
type === 'noscript' ||
typeof props.children === 'string' ||
typeof props.children === 'number' ||
(typeof props.dangerouslySetInnerHTML === 'object' &&
props.dangerouslySetInnerHTML !== null &&
props.dangerouslySetInnerHTML.\_\_html != null)

So for these elements react-dom renderer doesn’t create a separate text instance in the renderer.

For our custom renderer

For our case, lets set it to false, so that we get an instance even for text.

shouldSetTextContent: function(type, nextProps) {
  return false
}

▸ createTextInstance


The function signature is:

function createTextInstance(
  newText,
  rootContainerInstance,
  currentHostContext,
  workInProgress
) {
  return textNode
}

Parameters

  • newText: contains the text string that needs to be rendered.
  • rootContainerInstance: root dom node you specify while calling render. This is most commonly <div id="root"></div>
  • currentHostContext: contains the context from the host node enclosing this text node. For example, in the case of <p>Hello</p>: currentHostContext for Hello text node will be host context of p.
  • workInProgress: The fiber node for the text instance. This manages work for this instance.

Return Value This should be an actual text view element. In case of dom it would be a textNode.

Purpose

Here we specify how should renderer handle the text content

For our custom renderer

Lets just create a simple text node and return it.

createTextInstance: function(
  newText,
  rootContainerInstance,
  currentHostContext,
  workInProgress
) {
  return document.createTextNode(newText)
}

The reconciler is currently at the leaf text node in our traversal. Once it finishes text creation operation, it will move back up and call createInstance on the enclosing element.

▸ createInstance


The function signature is:

function createInstance(
  type,
  newProps,
  rootContainerInstance,
  currentHostContext,
  workInProgress
) {
  return domElement
}

Parameters

  • type: This contains the type of fiber i.e, ‘div’, ‘span’, ‘p’, ‘input’ etc.
  • nextProps: Contains the props passed to the host react element.
  • rootContainerInstance: root dom node you specify while calling render. This is most commonly <div id="root"></div>
  • currentHostContext: contains the context from the parent node enclosing this node. This is the return value from getChildHostContext of the parent node.
  • workInProgress: The fiber node for the text instance. This manages work for this instance.

Return Value This should be an actual dom element for the node.

Purpose

Create instance is called on all host nodes except the leaf text nodes. So we should return the correct view element for each host type here. We are also supposed to take care of the props sent to the host element. For example: setting up onClickListeners or setting up styling etc.

For our custom renderer

Lets just create the appropriate dom element and add all the attributes from the react element.

createInstance: function(
  type,
  newProps,
  rootContainerInstance,
  currentHostContext,
  workInProgress
) {
  const element = document.createElement(type)
  element.className = newProps.className || ''
  element.style = newProps.style
  // ....
  // ....
  // if (newProps.onClick) {
  //   element.addEventListener('click', newProps.onClick)
  // }
  return element
}

At this point it is important to understand that we are coming back up from recursion. So all child nodes have been instantiated and created. Hence, right after creating instance, we will need to attach the children to this node and that will be done in appendInitialChild.

▸ appendInitialChild


The function signature is:

function appendInitialChild(parent, child) {}

Parameters

  • parent: The current node in the traversal
  • child: The child dom node of the current node.

Purpose

Here we will attach the child dom node to the parent on the initial render phase. This method will be called for each child of the current node.

For our custom renderer

We will go ahead and attach the child nodes to the parent dom node.

appendInitialChild: (parent, child) => {
  parent.appendChild(child)
}

▸ finalizeInitialChildren


The function signature is:

function finalizeInitialChildren(
  instance,
  type,
  newProps,
  rootContainerInstance,
  currentHostContext
) {
  return Boolean
}

Parameters

  • instance: The instance is the dom element after appendInitialChild.
  • type: This contains the type of fiber i.e, ‘div’, ‘span’, ‘p’, ‘input’ etc.
  • newProps: Contains the props passed to the host react element.
  • rootContainerInstance: root dom node you specify while calling render. This is most commonly <div id="root"></div>
  • currentHostContext: contains the context from the parent node enclosing this node. This is the return value from getChildHostContext of the parent node.

Return Value A boolean value which decides if commitMount for this element needs to be called.

Purpose

In case of react native renderer, this function does nothing but return false.

In case of react-dom, this adds default dom properties such as event listeners, etc. For implementing auto focus for certain input elements (autofocus can happen only after render is done), react-dom sends return type as true. This results in commitMount method for this element to be called. The commitMount will be called only if an element returns true in finalizeInitialChildren and after the all elements of the tree has been rendered (even after resetAfterCommit).

For our custom renderer

We can return false in our case. But for experimental purpose, lets return true if autofocus is set to true. Make sure you implement a stub method for commitMount in hostConfig if you return true.

finalizeInitialChildren: (
  instance,
  type,
  newProps,
  rootContainerInstance,
  currentHostContext
) => {
  return newProps.autofocus //simply return true for experimenting
}

Now once all the child instances are done and finalised. Reconciler will move up the recursion to the parent of this node. Remember that the parent of the current node hasn’t been instantiated yet. So the reconciler will go up and call createInstanceappendInitialChildfinalizeInitialChildren on the parent. This cycle will happen till we reach the top of the recursion tree. When no more elements are left then prepareForCommit will be invoked.

▸ prepareForCommit


The function signature is:

function prepareForCommit(rootContainerInstance) {}

Parameters

  • rootContainerInstance: root dom node you specify while calling render. This is most commonly <div id="root"></div>

Purpose

This function is called when we have made a in-memory render tree of all the views (Remember we are yet to attach it the the actual root dom node). Here we can do any preparation that needs to be done on the rootContainer before attaching the in memory render tree. For example: In the case of react-dom, it keeps track of all the currently focused elements, disabled events temporarily, etc.

For our custom renderer

Since we are aiming at a simple renderer, this will be a no-op for us.

prepareForCommit = function (rootContainerInstance) {}

After prepareForCommit, the reconciler will commit the in memory tree to the rootHost following which the browser will trigger repaint.

▸ resetAfterCommit


The function signature is:

function resetAfterCommit(rootContainerInstance) {}

Parameters

  • rootContainerInstance: root dom node you specify while calling render. This is most commonly <div id="root"></div>

Purpose

This function gets executed after the inmemory tree has been attached to the root dom element. Here we can do any post attach operations that needs to be done. For example: react-dom re-enabled events which were temporarily disabled in prepareForCommit and refocuses elements, etc.

For our custom renderer

Since we are aiming at a simple renderer, this will be a no-op for us.

resetAfterCommit = function (rootContainerInstance) {}


Now after this, we expect our document to be rendererd but it DOES NOT. The issue is we dont have code that tells how to append our in-memory tree to the root div. The answer to this is appendChildToContainer

▸ appendChildToContainer


The function signature is:

function appendChildToContainer(parent, child) {}

Parameters

  • parent: The root div or the container.
  • child: The child dom node tree or the in-memory tree.

Purpose

Here we will our in-memory tree to the root host div. But this function only works if we set supportsMutation:true.

For our custom renderer

We will go ahead and attach the child nodes to the root dom node.

appendChildToContainer: (parent, child) => {
  parent.appendChild(child)
},
supportsMutation: true,

Demo

Now, lets refresh our React App!

working inital render


Holy!!! We just built our very own mini React 😎🎉🎊🤟

hell yeah

Before we finish

lets implement commitMount also.

▸ commitMount


The function signature is:

function commitMount(domElement, type, newProps, fiberNode) {}

Parameters

  • domElement: The rendered, attached dom element for this react element.
  • type: This contains the type of fiber i.e, ‘div’, ‘span’, ‘p’, ‘input’ etc.
  • newProps: Contains the props passed to the host react element.
  • fiberNode: The fiber node for the element. This manages work for this instance.

Purpose

This function is called for every element that has set the return value of finalizeInitialChildren to true. This method is called after all the steps are done (ie after resetAfterCommit), meaning the entire tree has been attached to the dom. This method is mainly used in react-dom for implementing autofocus. This method exists in react-dom only and not in react-native.

According to a comment in react-dom source code.

Despite the naming that might imply otherwise, this method only fires if there is an Update effect scheduled during mounting. This happens if finalizeInitialChildren returns true (which it does to implement the autoFocus attribute on the client). But there are also other cases when this might happen (such as patching up text content during hydration mismatch).

For our custom renderer

We will go ahead and call focus on the dom element.

commitMount: (domElement, type, newProps, fiberNode) => {
   domElement.focus();
},

Link to source code till here


Our HostConfig looks like this now:

const HostConfig = {
  now: Date.now,
  getRootHostContext: function (nextRootInstance) {
    let rootContext = {}
    return rootContext
  },
  getChildHostContext: function (parentContext, fiberType, rootInstance) {
    let context = { type: fiberType }
    return context
  },
  shouldSetTextContent: function (type, nextProps) {
    return false
  },
  createTextInstance: function (
    newText,
    rootContainerInstance,
    currentHostContext,
    workInProgress
  ) {
    return document.createTextNode(newText)
  },
  createInstance: function (
    type,
    newProps,
    rootContainerInstance,
    currentHostContext,
    workInProgress
  ) {
    const element = document.createElement(type)
    element.className = newProps.className || ''
    element.style = newProps.style
    // ....
    // ....
    // if (newProps.onClick) {
    //   element.addEventListener('click', newProps.onClick)
    // }
    return element
  },
  appendInitialChild: (parent, child) => {
    parent.appendChild(child)
  },
  finalizeInitialChildren: (
    instance,
    type,
    newProps,
    rootContainerInstance,
    currentHostContext
  ) => {
    return newProps.autofocus //simply return true for experimenting
  },
  prepareForCommit: function (rootContainerInstance) {},
  resetAfterCommit: function (rootContainerInstance) {},
  commitMount: (domElement, type, newProps, fiberNode) => {
    domElement.focus()
  },
  appendChildToContainer: (parent, child) => {
    parent.appendChild(child)
  },
  supportsMutation: true,
}

Cool! We have built a tiny renderer that can render JSX to the dom. Next part of the blog post series will focus solely on the update (which is why we use react in the first place 😉).

like a boss


Next part of the blog series will cover the update phase of the renderer after setState is invoked. ⚛️🤟 Part 3/3 - Beginners guide to Custom React Renderers. How to build your own renderer from scratch?



References

💌 Learn with me 🚀

I spend a lot of time learning and thinking about building better software. Subscribe and I'll drop a mail when I share something new.

No spam. Promise 🙏



Atul R

Written by Atul R a developer 🖥, author 📖 and trainer 👨🏽‍🎓. He primarily works on Javascript ecosystem and occasionally hacks around in C++, Rust and Python. He is an open source enthusiast and making useful tools for humans. You should follow him on Twitter