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
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.
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.
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 forreact-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.
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.
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.
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.
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!
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.