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

This is the final part of a 3-part series. Be sure to read Part 1 and Part 2 before proceeding.

This installment covers specifically the update phase of the renderer.

Overview of Current Progress

At this stage, the renderer successfully renders React applications into the DOM tree. The existing HostConfig includes methods for initial rendering: now, getRootHostContext, getChildHostContext, shouldSetTextContent, createTextInstance, createInstance, appendInitialChild, finalizeInitialChildren, prepareForCommit, resetAfterCommit, commitMount, appendChildToContainer, and supportsMutation.

Adding Interactivity

Let's add state management with a button that updates the current timestamp when clicked:

import React from 'react'
import CustomRenderer from './renderer'

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

class App extends React.Component {
  state = {
    text: Date.now(),
  }
  onButtonClick = () => {
    this.setState(() => ({ text: Date.now() }))
  }
  render() {
    return (
      <div>
        <Text className="hello-class" content={this.state.text} />
        <span style="color:blue;" autofocus>
          World
        </span>
        <button onClick={this.onButtonClick}>Get current time</button>
      </div>
    )
  }
}

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

Initially, clicking the button produces no visible effect. The renderer lacks handling for the onClick prop. Add event listener support to createInstance:

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;
  }

This triggers errors about missing prepareUpdate and commitTextUpdate methods - which we implement next.

Key Update Methods

prepareUpdate

function prepareUpdate(
  instance,
  type,
  oldProps,
  newProps,
  rootContainerInstance,
  currentHostContext
) {
  return { /* update payload */ }
}

Called during the render phase to signal whether updates are needed. No DOM changes should occur here - those belong in the commit phase. Returns a payload object indicating what needs to change.

prepareUpdate: function (
  instance,
  type,
  oldProps,
  newProps,
  rootContainerInstance,
  currentHostContext
) {
  return; // return nothing
}

commitUpdate

function commitUpdate(
  instance,
  updatePayload,
  type,
  oldProps,
  newProps,
  finishedWork
) {}

Executes all updates queued in prepareUpdate. This is where actual DOM manipulation occurs during the commit phase.

commitUpdate: function(
    instance,
    updatePayload,
    type,
    oldProps,
    newProps,
    finishedWork
  ) {
    return; // return nothing
  }

commitTextUpdate

function commitTextUpdate(textInstance, oldText, newText) {
  /** perform dom update on textInstance **/
}

Performs DOM updates on text nodes:

commitTextUpdate: function(textInstance, oldText, newText) {
    textInstance.nodeValue = newText;
}

After implementing commitTextUpdate, the button becomes functional and state updates are reflected in the UI.

Edge Case Methods

appendChild

Appends a new child element to the end of a parent. Triggered when conditionally rendered elements become visible:

appendChild: function(parentInstance, child) {
    parentInstance.appendChild(child);
}

insertBefore

Inserts a child before an existing sibling element:

insertBefore: (parentInstance, child, beforeChild) => {
  parentInstance.insertBefore(child, beforeChild)
}

removeChild

Removes a child element from its parent:

removeChild: function(parentInstance, child) {
 parentInstance.removeChild(child);
}

insertInContainerBefore

Inserts an element before another at the top-level component tree. Useful for Fragment components or multiple root elements:

insertInContainerBefore: function(container, child, beforeChild) {
  container.insertBefore(child, beforeChild);
}

removeChildFromContainer

Removes a top-level element from the root container:

removeChildFromContainer: function(container, child) {
  container.removeChild(child);
}

resetTextContent

Resets text content of an element. Leave as a no-op:

resetTextContent: function(domElement) {

}

shouldDeprioritizeSubtree

Enables performance optimization by deprioritizing rendering of hidden or offscreen subtrees:

shouldDeprioritizeSubtree: function(type, nextProps) {
  return !!nextProps.hidden
}

Complete HostConfig

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
  },
  prepareForCommit: function (rootContainerInstance) {},
  resetAfterCommit: function (rootContainerInstance) {},
  commitMount: (domElement, type, newProps, fiberNode) => {
    domElement.focus()
  },
  appendChildToContainer: (parent, child) => {
    parent.appendChild(child)
  },
  supportsMutation: true,
  prepareUpdate: function (
    instance,
    type,
    oldProps,
    newProps,
    rootContainerInstance,
    currentHostContext
  ) {
    return
  },
  commitUpdate: function (
    instance,
    updatePayload,
    type,
    oldProps,
    newProps,
    finishedWork
  ) {
    return
  },
  commitTextUpdate: function (textInstance, oldText, newText) {
    textInstance.nodeValue = newText
  },
  appendChild: function (parentInstance, child) {
    parentInstance.appendChild(child)
  },
  insertBefore: (parentInstance, child, beforeChild) => {
    parentInstance.insertBefore(child, beforeChild)
  },
  removeChild: function (parentInstance, child) {
    parentInstance.removeChild(child)
  },
  insertInContainerBefore: function (container, child, beforeChild) {
    container.insertBefore(child, beforeChild)
  },
  removeChildFromContainer: function (container, child) {
    container.removeChild(child)
  },
  resetTextContent: function (domElement) {},
  shouldDeprioritizeSubtree: function (type, nextProps) {
    return !!nextProps.hidden
  },
}

The complete source code is available on GitHub.

This post was ported over from Atul's old blog. Read the original here.