Introduction
React's power lies not in its DOM manipulation capabilities, but in its reconciliation algorithm—the process of computing the minimal set of changes needed to update a UI tree. This reconciliation logic is abstracted behind a "host config" interface that can target any rendering environment: the DOM, iOS/Android (React Native), WebGL (React Three Fiber), PDF documents (react-pdf), or even the terminal (Ink).
Building a custom React renderer is an advanced undertaking that reveals React's internal architecture at the deepest level. It's also one of the most powerful patterns in the React ecosystem—a single renderer can bring React's declarative programming model to any platform. This guide walks through the architecture, implementation, and production considerations for building your own React renderer from scratch.
Understanding React's Renderer Architecture: Core Concepts
The Reconciler and Host Config
React's architecture is split into two layers:
-
The Reconciler (
react-reconciler): Contains the fiber tree algorithm, scheduling logic, hooks implementation, and diffing algorithm. This is shared across all renderers. -
The Host Config: A plain object that the reconciler calls to perform platform-specific operations. This is what you implement when building a custom renderer. The host config defines how to create instances, append children, commit updates, and handle text.
When React needs to create a DOM node, it calls createInstance on the host config. When it needs to append a child, it calls appendChild. The reconciler handles when and what to update; the host config handles how to update.
Fibers and the Work Loop
React's fiber architecture is a linked list of work units. Each fiber represents a component instance, a DOM element, or a text node. The work loop processes fibers in two phases:
Render phase (can be interrupted): React traverses the fiber tree, calling component functions to produce new elements, and diffs them against the previous tree. This phase produces a "work-in-progress" tree.
Commit phase (synchronous): React applies the computed changes to the host environment. This is where your host config methods are called—createInstance, appendChild, removeChild, commitUpdate.
The Host Config Interface
The host config is a TypeScript interface with ~30 methods. The most important ones are:
createInstance(type, props): Create a new platform elementcreateTextInstance(text): Create a text nodeappendChild(parent, child): Add a child to a parentremoveChild(parent, child): Remove a childcommitUpdate(instance, payload): Apply prop changes to an instanceappendChildToContainer(container, child): Add a child to the root container
Architecture and Design Patterns
The Instance Model
Every custom renderer needs an instance model—a way to represent platform elements in memory. For a canvas renderer, an instance might be a shape object with position, size, and color properties. For a PDF renderer, it might be a text block with font, size, and coordinates. The instance model is the bridge between React's virtual tree and your platform's actual rendering.
The Container Pattern
React renderers operate on a container—the root element that holds the entire tree. For the DOM, the container is a DOM element (e.g., document.getElementById('root')). For a custom renderer, it might be a canvas context, a PDF document, or a terminal screen.
The Reconciliation Strategy
React's reconciler needs to know how to diff your instances. If a component's props change, the reconciler calls commitUpdate with the new props. Your implementation decides what "updating" means for your platform—redrawing a canvas shape, repositioning a PDF element, or updating a terminal cell.
Event Handling
Custom renderers need to handle events in their platform-specific way. A canvas renderer listens for mouse events on the canvas element and translates coordinates to hit-test against rendered shapes. A terminal renderer listens for keyboard input. The renderer can then call React's event system or implement its own.
Step-by-Step Implementation
Project Setup
mkdir react-custom-renderer && cd react-custom-renderer
npm init -y
npm install react react-reconciler
npm install --save-dev typescript @types/reactTypeScript Configuration
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}Defining the Host Config
We'll build a simple canvas renderer that draws rectangles, circles, and text on an HTML5 canvas.
// src/types.ts
export interface CanvasInstance {
type: string;
props: Record<string, any>;
children: CanvasInstance[];
parent: CanvasInstance | null;
}
export interface TextInstance {
type: "TEXT_INSTANCE";
text: string;
parent: CanvasInstance | null;
}// src/CanvasRenderer.ts
import Reconciler from "react-reconciler";
import { DefaultEventPriority } from "react-reconciler/constants";
import type { CanvasInstance, TextInstance } from "./types";
const canvasElements = new Map<CanvasInstance | TextInstance, CanvasRenderingContext2D>();
function createInstance(type: string, props: Record<string, any>): CanvasInstance {
return {
type,
props: { ...props },
children: [],
parent: null,
};
}
function createTextInstance(text: string): TextInstance {
return {
type: "TEXT_INSTANCE",
text,
parent: null,
};
}
function appendChild(parent: CanvasInstance, child: CanvasInstance | TextInstance) {
child.parent = parent;
parent.children.push(child);
}
function removeChild(parent: CanvasInstance, child: CanvasInstance | TextInstance) {
const index = parent.children.indexOf(child);
if (index !== -1) {
parent.children.splice(index, 1);
}
child.parent = null;
}
function insertBefore(
parent: CanvasInstance,
child: CanvasInstance | TextInstance,
beforeChild: CanvasInstance | TextInstance
) {
const index = parent.children.indexOf(beforeChild);
if (index !== -1) {
child.parent = parent;
parent.children.splice(index, 0, child);
}
}
function commitUpdate(
instance: CanvasInstance,
_type: string,
_oldProps: Record<string, any>,
newProps: Record<string, any>
) {
instance.props = { ...newProps };
}
function renderInstance(
ctx: CanvasRenderingContext2D,
instance: CanvasInstance | TextInstance,
x: number = 0,
y: number = 0
) {
if (instance.type === "TEXT_INSTANCE") {
ctx.fillStyle = instance.parent?.props?.color || "#000";
ctx.font = instance.parent?.props?.font || "16px sans-serif";
ctx.fillText(instance.text, x, y);
return;
}
const { width = 100, height = 100, fill = "#ccc", radius } = instance.props;
const instanceX = instance.props.x ?? x;
const instanceY = instance.props.y ?? y;
ctx.fillStyle = fill;
if (instance.type === "rect") {
ctx.fillRect(instanceX, instanceY, width, height);
} else if (instance.type === "circle") {
ctx.beginPath();
ctx.arc(instanceX + width / 2, instanceY + height / 2, radius || width / 2, 0, Math.PI * 2);
ctx.fill();
}
let childY = instanceY + 20;
for (const child of instance.children) {
renderInstance(ctx, child, instanceX + 10, childY);
childY += 20;
}
}
const reconcilerConfig = {
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: false,
createInstance,
createTextInstance,
appendChild,
removeChild,
insertBefore,
commitUpdate,
appendInitialChild: appendChild,
finalizeInitialChildren: () => false,
prepareUpdate: () => true,
prepareForCommit: () => null,
resetAfterCommit: () => {},
preparePortalMount: () => {},
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
getInstanceFromNode: () => null,
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
getInstanceFromScope: () => null,
detachDeletedInstance: () => {},
getChildHostContext: () => ({}),
getPublicInstance: (instance: CanvasInstance) => instance,
getRootHostContext: () => ({}),
shouldSetTextContent: () => false,
clearContainer: () => {},
getCurrentEventPriority: () => DefaultEventPriority,
};
const CanvasReconciler = Reconciler(reconcilerConfig);
export function renderToCanvas(
element: React.ReactNode,
canvas: HTMLCanvasElement
) {
const ctx = canvas.getContext("2d")!;
const container = CanvasReconciler.createContainer(
canvas, // container
0, // tag (LegacyRoot)
null, // hydrationCallbacks
false, // isStrictMode
false, // concurrentUpdatesByDefaultOverride
"", // identifierPrefix
(error: Error) => console.error(error), // onUncaughtError
(error: Error) => console.error(error), // onCaughtError
);
CanvasReconciler.updateContainer(element, container, null, () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const root = canvas as any;
if (root.children) {
for (const child of root.children) {
renderInstance(ctx, child);
}
}
});
return {
unmount: () => CanvasReconciler.updateContainer(null, container, null, () => {}),
};
}Using the Renderer
// src/App.tsx
import React from "react";
function CanvasApp() {
return (
<>
<rect x={10} y={10} width={200} height={100} fill="#4A90D9">
<text color="#fff" font="bold 18px sans-serif">Hello Canvas!</text>
</rect>
<circle x={250} y={50} width={80} height={80} fill="#E74C3C" radius={40} />
</>
);
}
// Mount in a browser
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const { unmount } = renderToCanvas(<CanvasApp />, canvas);Real-World Use Cases
React Three Fiber (3D Graphics)
React Three Fiber is the most successful custom React renderer. It maps React components to Three.js objects, enabling declarative 3D scene construction. A <mesh> component creates a Three.js Mesh, a <boxGeometry> creates geometry, and React's reconciler handles the Three.js scene graph updates.
Ink (Terminal UIs)
Ink renders React components to the terminal. Components like <Box>, <Text>, and <Color> map to terminal cells using a Yoga-based layout engine. Developers build CLI tools with the same component model they use for web UIs.
react-pdf (PDF Generation)
react-pdf renders React components to PDF documents. Components like <Page>, <View>, <Text>, and <Image> map to PDF primitives. The renderer handles pagination, font embedding, and PDF stream generation.
React Native (Mobile)
React Native is the original custom renderer. It maps React components to native iOS and Android views via a bridge. The renderer handles view creation, layout (via Yoga), style application, and event dispatching.
Best Practices for Production
-
Start with the minimum host config — Implement only
createInstance,createTextInstance,appendChild,removeChild, andcommitUpdatefirst. Add more methods as needed. -
Use TypeScript for the host config — The
react-reconcilerpackage has TypeScript definitions that enforce the correct method signatures. Use them to catch errors early. -
Test with
react-test-rendererpatterns — Create a test environment that renders your components and asserts on the instance tree. This catches reconciliation bugs before they reach production. -
Handle the commit phase carefully — The commit phase is synchronous and cannot be interrupted. Keep side effects (DOM updates, canvas redraws) in the commit phase, not the render phase.
-
Implement
prepareUpdateefficiently — This method is called on every render to determine if an update is needed. Returnnullif nothing changed to avoid unnecessary commit work. -
Support concurrent mode — If your renderer will be used with concurrent React features, implement
getCurrentEventPriorityand handle interruptible work correctly. -
Document your component API — Custom renderers define their own component vocabulary. Document each component's props, default values, and behavior clearly.
-
Provide a development mode — Add warnings for invalid props, missing required fields, and common mistakes. React's development mode provides hooks for this.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Not implementing removeChild | Memory leaks, stale instances | Always clean up parent-child references on removal |
| Mutating props directly | Stale renders, React warnings | Always create new prop objects in commitUpdate |
| Blocking the commit phase | UI freezes | Keep expensive operations (canvas redraws) batched and async |
| Not handling text instances | Text children render incorrectly | Implement createTextInstance and handle TEXT_INSTANCE in all child operations |
Forgetting supportsMutation | Renderer won't work | Set supportsMutation: true in the config |
| Concurrent mode issues | Glitches, double renders | Use getCurrentEventPriority and handle interruptions |
Performance Optimization
Batched Canvas Redraws
Instead of redrawing the canvas on every commitUpdate, batch updates and redraw once per animation frame:
let needsRedraw = false;
function commitUpdate(instance: CanvasInstance, ...args: any[]) {
instance.props = { ...args[3] };
needsRedraw = true;
}
function scheduleRedraw(canvas: HTMLCanvasElement) {
if (needsRedraw) {
requestAnimationFrame(() => {
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Redraw entire tree
needsRedraw = false;
});
}
}Instance Pooling
For renderers that create and destroy many instances (e.g., a particle system), pool instances instead of garbage-collecting them:
const instancePool: CanvasInstance[] = [];
function createInstance(type: string, props: Record<string, any>): CanvasInstance {
const instance = instancePool.pop() || { type: "", props: {}, children: [], parent: null };
instance.type = type;
instance.props = { ...props };
instance.children = [];
instance.parent = null;
return instance;
}
function removeChild(parent: CanvasInstance, child: CanvasInstance | TextInstance) {
const index = parent.children.indexOf(child);
if (index !== -1) parent.children.splice(index, 1);
child.parent = null;
if (child.type !== "TEXT_INSTANCE") {
instancePool.push(child);
}
}Comparison with Alternatives
| Approach | Declarative | Performance | Ecosystem | Learning Curve |
|---|---|---|---|---|
| Custom React Renderer | Yes | High | React ecosystem | High |
| Direct DOM manipulation | No | Highest | None | Low |
| Virtual DOM library (Preact) | Yes | High | Small | Medium |
| Imperative canvas library | No | High | Small | Low |
| Web Components | Yes | Medium | Growing | Medium |
Advanced Patterns
Portals for Overlay Rendering
Support rendering to multiple targets (e.g., a main canvas and an overlay canvas) using React portals:
import { createPortal } from "react-dom";
function Overlay({ children }: { children: React.ReactNode }) {
const overlayCanvas = document.getElementById("overlay")!;
return createPortal(children, overlayCanvas);
}Custom Hooks for Renderer State
function useCanvasSize(canvasRef: React.RefObject<HTMLCanvasElement>) {
const [size, setSize] = React.useState({ width: 0, height: 0 });
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
});
observer.observe(canvas);
return () => observer.disconnect();
}, [canvasRef]);
return size;
}Testing Strategies
// __tests__/renderer.test.ts
import { createInstance, createTextInstance, appendChild } from "../src/CanvasRenderer";
describe("Canvas Renderer", () => {
it("creates instances with correct type and props", () => {
const instance = createInstance("rect", { width: 100, height: 50, fill: "red" });
expect(instance.type).toBe("rect");
expect(instance.props.width).toBe(100);
expect(instance.children).toEqual([]);
});
it("creates text instances", () => {
const text = createTextInstance("Hello");
expect(text.type).toBe("TEXT_INSTANCE");
expect(text.text).toBe("Hello");
});
it("appends children correctly", () => {
const parent = createInstance("div", {});
const child = createInstance("span", {});
appendChild(parent, child);
expect(parent.children).toContain(child);
expect(child.parent).toBe(parent);
});
it("handles nested children", () => {
const root = createInstance("root", {});
const group = createInstance("group", {});
const rect = createInstance("rect", { fill: "blue" });
appendChild(root, group);
appendChild(group, rect);
expect(root.children).toContain(group);
expect(group.children).toContain(rect);
expect(rect.parent).toBe(group);
});
});Testing Custom Renderers
Testing custom renderers requires creating a test harness that exercises the host config methods. Build a minimal test renderer that captures the output of createInstance, appendChild, and commitUpdate calls rather than performing real side effects. This allows you to verify that your renderer correctly translates the React component tree into the expected host operations without depending on a specific target environment.
The react-test-renderer package provides a reference implementation that you can study to understand the testing patterns. Create snapshot tests that render a component tree and compare the captured host operations against a known-good baseline. This catches regressions in how your renderer handles props, children, and state updates. Use property-based testing to generate random component trees and verify that your renderer handles all valid configurations without crashing.
Integration tests should verify the end-to-end behavior of your renderer in its target environment. For a canvas renderer, render a known scene and compare the pixel output against a reference image. For a terminal renderer, capture the terminal output and verify the expected text and formatting. These tests catch issues that unit tests miss, like incorrect coordinate transformations, missing event handling, or rendering artifacts from improper cleanup.
Future Outlook
React's renderer API continues to evolve. The react-reconciler package is being updated to support React's concurrent features, server components, and the new React compiler. Custom renderers that implement the full host config will benefit from these improvements automatically.
The ecosystem of custom renderers is growing. Beyond the established players (React Native, React Three Fiber, Ink), new renderers are emerging for WebGPU, WebAssembly, and even hardware interfaces. The pattern of using React's declarative model for non-DOM targets is becoming a standard architectural choice.
The React team has signaled that the reconciler API will stabilize further, making it easier to build and maintain custom renderers. Combined with React Server Components, this opens the possibility of renderers that span client and server—rendering a 3D scene on the server for SEO, then hydrating it on the client for interactivity.
Conclusion
Building a custom React renderer is one of the most advanced React projects you can undertake. The key takeaways:
- React is a reconciler, not a DOM library — The reconciler algorithm is platform-agnostic and can target any rendering environment
- The host config is your API — Implement ~15-20 methods to bridge React's virtual tree to your platform's real objects
- Start small, iterate — Begin with
createInstance,appendChild, andcommitUpdate. Add complexity as needed - The instance model matters most — How you represent platform elements in memory determines your renderer's capabilities and performance
- Real-world renderers validate the pattern — React Three Fiber, Ink, and react-pdf prove that custom renderers can be production-grade
Start by reading the source code of React Three Fiber or Ink. Both are well-structured and demonstrate the host config patterns you'll need. Then build a minimal renderer for a simple target—a canvas, a terminal, or even a JSON structure. The exercise will deepen your understanding of React more than any tutorial can.