Skip to content

How to Create a Custom React Renderer

Creating a Custom React Renderer

At the very top of the React documentation, the team defines React's main qualities:

  • Declarative
  • Component-Based
  • Learn Once, Write Anywhere
React Features

The main focus of the React docs is to demonstrate the first 2 qualities of React: its declarative nature, and how it allows the developer to break logic down into components.

The main goal of this article will be to expand upon that third quality of React: "Learn Once, Write Anywhere."

Requirements

To follow along more easily with this post, you should already know a few things:

  • React: This post doesn't teach you how to declaratively write React, but instead dives into how React communicates with the DOM. Understanding how to write generic React code would be great foundational knowledge before diving into how it works under the hood.
  • The DOM: A lot of the interactions between React and the DOM are abstracted away under the 'react-dom' package. Having a good understanding of how to render to the DOM with vanilla JavaScript will be incredibly useful since we will be implementing this functionality ourselves.

I've also created a repository based on the create react app starter, and added some nice-to-have features to it including:

  • TypeScript
  • ESLint
  • Prettier
  • Husky w/ pre-commit linting
  • Tailwind CSS

You can test out your code using a vanilla create-react-app installation, but I enjoy these developer tools, so I wanted to offer a configured setup that uses them. Feel free to clone and use the repo!

React DOM

I'm sure that every single React developer has run create-react-app at least once. When creating a CRA app, 99% of your time is usually spent expanding the functionality of the App component. Very little time is spent on the piece of logic that actually renders the App component.

ReactDOM Rendering

This line is responsible for taking our React App component, and then mounting all of its components along with event handlers, to the DOM. We usually never need to worry about how React does this. Instead, we focus on declaratively adding functionality to the App components. Rendering to the DOM is abstracted away into this one line.

This is similar to working with React Native. We develop Native App Components, but we don't think about how those components are rendered to different devices. React Native handles that for us, just like how ReactDOM handles rendering to the DOM for us.

The Test Application

To test out experimenting with our own custom React renderer, I've created a repository forked off of a create-react-app install, with added dev features like linting, git commit hooks, and TailwindCSS. Before diving into replacing ReactDOM, let's look at what our application can look like at the start.

Test Application Source
Test Application Output

Replacing ReactDOM

To replace the react-dom renderer with our own, we'll need to import 2 dependencies:

  • react-reconciler: This exposes a function that takes a host configuration object, allowing us to customize rendering to whatever format we desire.
  • @types/react-reconciler: Types for react-reconciler

After installing these dependencies, we can replace ReactDOM with our new renderer, and then with the help of TypeScript, stub out the remaining portions of our new Renderer.

Replacing ReactDOM

What does a React renderer look like?

The React team exposes their react-reconciler as a function to allow third parties to create custom renderers. This reconciler function takes one argument: a Host Configuration object that's methods provide an interface with which React can render to a host environment.

Using Host Configuration

The methods of the host configuration object map out to different methods of the configured host environment, allowing the developer to abstract away the process of rendering and updating the state to the environment.

import Reconciler from "react-reconciler";

const hostConfig = {
  // methods such as createInstance and appendChild
}

const reconciler = Reconciler(hostConfig);

const ReactRenderer = {
  render(component: any, container: any) {
    const root = reconciler.createContainer(container, 0, false, null);
    reconciler.updateContainer(component, root, null);
  },
};

For example, here is real code from react-dom, which defines how to append a child in the DOM.

React DOM append child

In our example exploring this, we'll try and minimally recreate react-dom, so we can render our sample app to the DOM.

Host Configuration

With a stubbed DOM host configuration, and having replaced ReactDOM with our custom renderer, we are now able to run the CRA dev server without errors. However, nothing has been rendered to the DOM yet.

Our host configuration method stubs included console.log's, showing when these methods get called though, so the log has a lot of activity.

Host Config Logging

We can see bits and pieces of our App component in these logs, but since our host configuration did not actually mount anything to the DOM, our screen remains blank. Let's fill out a few of our functions to implement this behavior:

  • createInstance
  • createTextInstance
  • appendInitialChild
  • appendChild
  • appendChildToContainer

TypeScript does a lot of mental heavy lifting by allowing us to define types and enhancing our development with auto-complete for implementing these host config functions.

Types and generic host config signature:

type Type = string;
type Props = { [key: string]: any };
type Container = Document | Element;
type Instance = Element;
type TextInstance = Text;

type SuspenseInstance = any;
type HydratableInstance = any;
type PublicInstance = any;
type HostContext = any;
type UpdatePayload = any;
type _ChildSet = any;
type TimeoutHandle = any;
type NoTimeout = number;

const hostConfig: HostConfig<
  Type,
  Props,
  Container,
  Instance,
  TextInstance,
  SuspenseInstance,
  HydratableInstance,
  PublicInstance,
  HostContext,
  UpdatePayload,
  _ChildSet,
  TimeoutHandle,
  NoTimeout
> = {
  // hostConfiguration
}

The createInstance function:

createInstance(
  type: Type,
  props: Props,
  rootContainer: Container,
  hostContext: HostContext,
  internalHandle: OpaqueHandle
): Instance {
  const element = document.createElement(type) as Element;

  if (props.className) element.className = props.className;
  if (props.id) element.id = props.id;

  return element;
},

The createTextInstance function:

createTextInstance(
  text: string,
  rootContainer: Container,
  hostContext: HostContext,
  internalHandle: OpaqueHandle
): TextInstance {
  const textElement = document.createTextNode(text);
  return textElement;
},

The appendChild function:

appendChild(parentInstance: Instance, child: Instance | TextInstance): void {
  parentInstance.appendChild(child);
},

In the end, it was just a few familiar DOM calls until we were able to render our application once again, except this time, with our own renderer!

Custom Renderer Rendering

Conclusion

With just a few method definitions, we are now able to render to the DOM, but we could have also just as easily issued commands to draw on a canvas when trying to render our components, or we could have rendered differently.

By learning React once, you can apply it in a number of scenarios. By separating rendering logic from reconciliation logic, React allows third-party developers to create custom renderers. This allows developers to render whereever they want, be it in the canvas, or even to the console.