Building Your Own React Renderer
Building Your Own React Renderer
React isn't a DOM library. It's a reconciliation engine that happens to ship with a DOM renderer. The same mental model that powers your web app can render to Canvas, terminals, PDF documents, native mobile views, or any target you can imagine.
Understanding how to build a custom renderer isn't just an academic exercise. It unlocks deep knowledge of React's internals—the Fiber architecture, the reconciliation algorithm, the scheduler—that directly translates to better debugging and performance optimization in production applications.
This is a deep dive into react-reconciler, the Fiber architecture, and building renderers that actually work.
Why Custom Renderers Matter
┌─────────────────────────────────────────────────────────────────────────────┐
│ REACT'S RENDER TARGETS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ React Core (reconciler) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ - Component model │ │
│ │ - State management │ │
│ │ - Lifecycle (effects) │ │
│ │ - Diffing algorithm │ │
│ │ - Scheduling │ │
│ │ - Concurrent features │ │
│ │ │ │
│ │ ▲ This is react-reconciler ▲ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ react-dom │ │ react-native │ │ Your custom │ │
│ │ │ │ │ │ renderer │ │
│ │ DOM nodes │ │ Native views│ │ Canvas, PDF │ │
│ │ Browser API │ │ iOS/Android │ │ Terminal │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ Same React, different hosts │
│ │
│ Production examples: │
│ - react-three-fiber: Three.js / WebGL │
│ - react-pdf: PDF documents │
│ - ink: Terminal UIs │
│ - react-figma: Figma plugins │
│ - react-hardware: Arduino/IoT │
│ - remotion: Video rendering │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Fiber Architecture: The Foundation
Before building a renderer, you need to understand Fiber—React's internal representation of a component tree.
What is a Fiber?
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIBER NODE STRUCTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ A Fiber is a JavaScript object representing a unit of work │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Fiber { │ │
│ │ // Identity │ │
│ │ tag: FunctionComponent | HostComponent | HostRoot | ... │ │
│ │ key: string | null │ │
│ │ type: Function | string | Symbol // Component or element type │ │
│ │ │ │
│ │ // Tree structure (linked list, not array!) │ │
│ │ return: Fiber | null // Parent fiber │ │
│ │ child: Fiber | null // First child │ │
│ │ sibling: Fiber | null // Next sibling │ │
│ │ index: number // Position in parent's children │ │
│ │ │ │
│ │ // Input/Output │ │
│ │ pendingProps: any // New props │ │
│ │ memoizedProps: any // Props from last render │ │
│ │ memoizedState: any // State from last render │ │
│ │ │ │
│ │ // Effects │ │
│ │ flags: Flags // Side effects to perform │ │
│ │ subtreeFlags: Flags // Side effects in subtree │ │
│ │ updateQueue: any // State updates queue │ │
│ │ │ │
│ │ // Host-specific (YOUR RENDERER TOUCHES THIS) │ │
│ │ stateNode: any // DOM node, Canvas object, etc. │ │
│ │ │ │
│ │ // Alternate (double buffering) │ │
│ │ alternate: Fiber | null // Previous/next version │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Fiber Tree Structure
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIBER TREE TRAVERSAL │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Component tree: │
│ <App> │
│ <Header /> │
│ <Main> │
│ <Article /> │
│ <Sidebar /> │
│ </Main> │
│ </App> │
│ │
│ Fiber structure (linked list): │
│ │
│ ┌─────────┐ │
│ │HostRoot │ (FiberRoot) │
│ └────┬────┘ │
│ │ child │
│ ▼ │
│ ┌─────────┐ │
│ │ App │ │
│ └────┬────┘ │
│ │ child │
│ ▼ │
│ ┌─────────┐ sibling ┌─────────┐ │
│ │ Header │────────►│ Main │ │
│ └─────────┘ └────┬────┘ │
│ ▲ │ child │
│ │ return ▼ │
│ │ ┌─────────┐ sibling ┌─────────┐ │
│ └──────────────│ Article │────────►│ Sidebar │ │
│ └─────────┘ └─────────┘ │
│ │ │ │
│ └───────┬───────────┘ │
│ │ return │
│ ▼ │
│ ┌─────────┐ │
│ │ Main │ │
│ └─────────┘ │
│ │
│ Why linked list instead of arrays? │
│ - Enables incremental rendering (can pause/resume anywhere) │
│ - O(1) insertions and deletions │
│ - Natural work loop: child → sibling → return to parent │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Double Buffering: Current vs Work-in-Progress
┌─────────────────────────────────────────────────────────────────────────────┐
│ FIBER DOUBLE BUFFERING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ CURRENT TREE │ │ WORK-IN-PROGRESS │ │
│ │ (what's on screen) │ │ (being built) │ │
│ │ │ │ │ │
│ │ ┌─────┐ │ │ ┌─────┐ │ │
│ │ │ App │ │ │ │ App │ │ │
│ │ └──┬──┘ │ │ └──┬──┘ │ │
│ │ │ │ │ │ │ │
│ │ ┌──┴──┐ │ │ ┌──┴──┐ │ │
│ │ │count│ │ │ │count│ │ │
│ │ │ =0 │ │ │ │ =1 │ ◄─ new value │ │
│ │ └─────┘ │ │ └─────┘ │ │
│ │ │ │ │ │
│ └───────────┬─────────────┘ └───────────┬─────────────┘ │
│ │ │ │
│ │ .alternate │ │
│ └────────────────────────────────┘ │
│ │
│ Process: │
│ 1. Render phase: Build work-in-progress tree │
│ 2. Commit phase: Swap current ↔ work-in-progress │
│ 3. Old current becomes new work-in-progress (recycled) │
│ │
│ Benefits: │
│ - Current tree is always consistent (no partial updates visible) │
│ - Can abort work-in-progress without affecting screen │
│ - Reuse fiber objects (less GC pressure) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
react-reconciler: The API
The react-reconciler package exposes a function that takes a host config and returns a renderer. The host config is where you implement platform-specific operations.
Host Config Interface
// types.ts
import type Reconciler from 'react-reconciler';
// Your host's instance types
type Type = string; // Element type (e.g., 'rect', 'text')
type Props = Record<string, unknown>; // Element props
type Container = YourRootContainer; // Root container
type Instance = YourElement; // Created element instance
type TextInstance = YourTextElement; // Text node instance
type ChildSet = YourElement[]; // For persistent mode
// The host config you must implement
type HostConfig = Reconciler.HostConfig<
Type, // Type
Props, // Props
Container, // Container
Instance, // Instance
TextInstance, // TextInstance
unknown, // SuspenseInstance
unknown, // HydratableInstance
Instance, // PublicInstance
object, // HostContext
unknown, // UpdatePayload
ChildSet, // ChildSet (mutation mode: never, persistent: array)
number, // TimeoutHandle
number // NoTimeout
>;
Essential Host Config Methods
// renderer/hostConfig.ts
import Reconciler from 'react-reconciler';
import { DefaultEventPriority } from 'react-reconciler/constants';
const hostConfig: Reconciler.HostConfig<...> = {
// ============================================
// CREATION
// ============================================
/**
* Create a host element instance
* Called when React needs to create a new element
*/
createInstance(
type: string,
props: Props,
rootContainer: Container,
hostContext: object,
internalHandle: Reconciler.OpaqueHandle
): Instance {
// Example: Canvas renderer
return {
type,
props,
children: [],
// Canvas-specific properties
x: props.x ?? 0,
y: props.y ?? 0,
width: props.width ?? 0,
height: props.height ?? 0,
};
},
/**
* Create a text instance
* Called for text content
*/
createTextInstance(
text: string,
rootContainer: Container,
hostContext: object,
internalHandle: Reconciler.OpaqueHandle
): TextInstance {
return {
type: 'TEXT',
text,
};
},
// ============================================
// TREE OPERATIONS (Mutation Mode)
// ============================================
/**
* Append a child to a container (root)
*/
appendChildToContainer(container: Container, child: Instance): void {
container.children.push(child);
container.needsRedraw = true;
},
/**
* Append a child to a parent instance
*/
appendChild(parent: Instance, child: Instance | TextInstance): void {
parent.children.push(child);
},
/**
* Insert a child before another child
*/
insertBefore(
parent: Instance,
child: Instance | TextInstance,
beforeChild: Instance | TextInstance
): void {
const index = parent.children.indexOf(beforeChild);
if (index !== -1) {
parent.children.splice(index, 0, child);
}
},
/**
* Remove a child from parent
*/
removeChild(parent: Instance, child: Instance | TextInstance): void {
const index = parent.children.indexOf(child);
if (index !== -1) {
parent.children.splice(index, 1);
}
},
removeChildFromContainer(container: Container, child: Instance): void {
const index = container.children.indexOf(child);
if (index !== -1) {
container.children.splice(index, 1);
}
container.needsRedraw = true;
},
// ============================================
// UPDATES
// ============================================
/**
* Prepare an update (diff props)
* Called during render phase
* Return null if no update needed, otherwise return update payload
*/
prepareUpdate(
instance: Instance,
type: string,
oldProps: Props,
newProps: Props,
rootContainer: Container,
hostContext: object
): object | null {
// Find changed props
const updatePayload: Record<string, unknown> = {};
let hasChanges = false;
for (const key in newProps) {
if (key === 'children') continue;
if (oldProps[key] !== newProps[key]) {
updatePayload[key] = newProps[key];
hasChanges = true;
}
}
for (const key in oldProps) {
if (key === 'children') continue;
if (!(key in newProps)) {
updatePayload[key] = undefined;
hasChanges = true;
}
}
return hasChanges ? updatePayload : null;
},
/**
* Apply the update
* Called during commit phase
*/
commitUpdate(
instance: Instance,
updatePayload: object,
type: string,
prevProps: Props,
nextProps: Props,
internalHandle: Reconciler.OpaqueHandle
): void {
// Apply changes to instance
Object.assign(instance, updatePayload);
instance.props = nextProps;
},
/**
* Update text content
*/
commitTextUpdate(
textInstance: TextInstance,
oldText: string,
newText: string
): void {
textInstance.text = newText;
},
// ============================================
// REQUIRED CONFIGURATION
// ============================================
supportsMutation: true, // We modify the tree in place
supportsPersistence: false, // Not using immutable updates
supportsHydration: false, // Not doing SSR hydration
isPrimaryRenderer: true, // Main renderer (not portal)
// Scheduling
getCurrentEventPriority: () => DefaultEventPriority,
getInstanceFromNode: () => null,
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
prepareScopeUpdate: () => {},
getInstanceFromScope: () => null,
detachDeletedInstance: () => {},
// Context
getRootHostContext: () => ({}),
getChildHostContext: (parentContext) => parentContext,
// Misc
getPublicInstance: (instance) => instance,
prepareForCommit: () => null,
resetAfterCommit: (container) => {
// Trigger redraw after all mutations
container.draw();
},
preparePortalMount: () => {},
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
// Text handling
shouldSetTextContent: (type, props) => {
// Return true if this element type handles its own text
return type === 'text';
},
finalizeInitialChildren: () => false,
clearContainer: (container) => {
container.children = [];
},
};
export default hostConfig;
Building a Canvas Renderer
Let's build a complete renderer that draws React components to an HTML Canvas.
The Canvas Element Types
// canvas-renderer/types.ts
export interface BaseElement {
type: string;
props: Record<string, unknown>;
children: (CanvasElement | TextElement)[];
}
export interface RectElement extends BaseElement {
type: 'rect';
props: {
x: number;
y: number;
width: number;
height: number;
fill?: string;
stroke?: string;
strokeWidth?: number;
cornerRadius?: number;
onClick?: () => void;
};
}
export interface CircleElement extends BaseElement {
type: 'circle';
props: {
cx: number;
cy: number;
radius: number;
fill?: string;
stroke?: string;
};
}
export interface TextElement extends BaseElement {
type: 'text';
props: {
x: number;
y: number;
text?: string;
fontSize?: number;
fontFamily?: string;
fill?: string;
textAlign?: CanvasTextAlign;
};
}
export interface GroupElement extends BaseElement {
type: 'group';
props: {
x?: number;
y?: number;
rotation?: number;
scaleX?: number;
scaleY?: number;
};
}
export type CanvasElement = RectElement | CircleElement | TextElement | GroupElement;
export interface CanvasContainer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
children: CanvasElement[];
width: number;
height: number;
draw: () => void;
}
The Host Config Implementation
// canvas-renderer/hostConfig.ts
import Reconciler from 'react-reconciler';
import { DefaultEventPriority } from 'react-reconciler/constants';
import type { CanvasContainer, CanvasElement } from './types';
function createInstance(type: string, props: Record<string, unknown>): CanvasElement {
return {
type,
props,
children: [],
} as CanvasElement;
}
const hostConfig: Reconciler.HostConfig<
string, // Type
Record<string, any>, // Props
CanvasContainer, // Container
CanvasElement, // Instance
never, // TextInstance (we don't support raw text)
never, // SuspenseInstance
never, // HydratableInstance
CanvasElement, // PublicInstance
{}, // HostContext
Record<string, any>, // UpdatePayload
never, // ChildSet
ReturnType<typeof setTimeout>, // TimeoutHandle
-1 // NoTimeout
> = {
// Creation
createInstance(type, props) {
return createInstance(type, props);
},
createTextInstance(text) {
// Canvas doesn't have text nodes; text is a prop
throw new Error(
'Canvas renderer does not support raw text. Use <text> element instead.'
);
},
// Tree operations
appendChildToContainer(container, child) {
container.children.push(child);
},
appendChild(parent, child) {
parent.children.push(child);
},
appendInitialChild(parent, child) {
parent.children.push(child);
},
insertBefore(parent, child, beforeChild) {
const index = parent.children.indexOf(beforeChild);
if (index !== -1) {
parent.children.splice(index, 0, child);
} else {
parent.children.push(child);
}
},
insertInContainerBefore(container, child, beforeChild) {
const index = container.children.indexOf(beforeChild);
if (index !== -1) {
container.children.splice(index, 0, child);
} else {
container.children.push(child);
}
},
removeChild(parent, child) {
const index = parent.children.indexOf(child);
if (index !== -1) {
parent.children.splice(index, 1);
}
},
removeChildFromContainer(container, child) {
const index = container.children.indexOf(child);
if (index !== -1) {
container.children.splice(index, 1);
}
},
// Updates
prepareUpdate(instance, type, oldProps, newProps) {
const diff: Record<string, unknown> = {};
let hasChanges = false;
// Compare props
const allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]);
allKeys.delete('children');
for (const key of allKeys) {
if (oldProps[key] !== newProps[key]) {
diff[key] = newProps[key];
hasChanges = true;
}
}
return hasChanges ? diff : null;
},
commitUpdate(instance, updatePayload, type, prevProps, nextProps) {
instance.props = { ...instance.props, ...updatePayload };
},
commitTextUpdate() {
// No-op (we don't support text instances)
},
// Configuration
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
getCurrentEventPriority: () => DefaultEventPriority,
getInstanceFromNode: () => null,
beforeActiveInstanceBlur: () => {},
afterActiveInstanceBlur: () => {},
prepareScopeUpdate: () => {},
getInstanceFromScope: () => null,
detachDeletedInstance: () => {},
getRootHostContext: () => ({}),
getChildHostContext: (parentContext) => parentContext,
getPublicInstance: (instance) => instance,
prepareForCommit: () => null,
resetAfterCommit: (container) => {
// This is where we trigger the actual canvas redraw
container.draw();
},
preparePortalMount: () => {},
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
shouldSetTextContent: () => false,
finalizeInitialChildren: (instance, type, props) => {
// Return true if this instance needs focus after mount
return false;
},
clearContainer: (container) => {
container.children = [];
},
};
export default hostConfig;
The Renderer and Drawing Logic
// canvas-renderer/index.ts
import Reconciler from 'react-reconciler';
import hostConfig from './hostConfig';
import type { CanvasContainer, CanvasElement } from './types';
// Create the reconciler
const reconciler = Reconciler(hostConfig);
// Enable concurrent features
reconciler.injectIntoDevTools({
bundleType: process.env.NODE_ENV === 'development' ? 1 : 0,
version: '1.0.0',
rendererPackageName: 'canvas-renderer',
});
// Drawing functions
function drawElement(
ctx: CanvasRenderingContext2D,
element: CanvasElement,
parentTransform: { x: number; y: number } = { x: 0, y: 0 }
) {
const { type, props, children } = element;
ctx.save();
switch (type) {
case 'rect': {
const { x, y, width, height, fill, stroke, strokeWidth, cornerRadius } = props;
const absX = parentTransform.x + (x || 0);
const absY = parentTransform.y + (y || 0);
ctx.beginPath();
if (cornerRadius) {
ctx.roundRect(absX, absY, width, height, cornerRadius);
} else {
ctx.rect(absX, absY, width, height);
}
if (fill) {
ctx.fillStyle = fill;
ctx.fill();
}
if (stroke) {
ctx.strokeStyle = stroke;
ctx.lineWidth = strokeWidth || 1;
ctx.stroke();
}
break;
}
case 'circle': {
const { cx, cy, radius, fill, stroke } = props;
const absX = parentTransform.x + (cx || 0);
const absY = parentTransform.y + (cy || 0);
ctx.beginPath();
ctx.arc(absX, absY, radius, 0, Math.PI * 2);
if (fill) {
ctx.fillStyle = fill;
ctx.fill();
}
if (stroke) {
ctx.strokeStyle = stroke;
ctx.stroke();
}
break;
}
case 'text': {
const { x, y, text, fontSize, fontFamily, fill, textAlign } = props;
const absX = parentTransform.x + (x || 0);
const absY = parentTransform.y + (y || 0);
ctx.font = `${fontSize || 16}px ${fontFamily || 'sans-serif'}`;
ctx.textAlign = textAlign || 'left';
ctx.fillStyle = fill || 'black';
ctx.fillText(text || '', absX, absY);
break;
}
case 'group': {
const { x, y, rotation, scaleX, scaleY } = props;
const newTransform = {
x: parentTransform.x + (x || 0),
y: parentTransform.y + (y || 0),
};
if (rotation) {
ctx.translate(newTransform.x, newTransform.y);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-newTransform.x, -newTransform.y);
}
if (scaleX || scaleY) {
ctx.scale(scaleX || 1, scaleY || 1);
}
// Draw children with new transform
for (const child of children) {
drawElement(ctx, child, newTransform);
}
break;
}
}
// Draw children for non-group elements
if (type !== 'group') {
const childTransform = {
x: parentTransform.x + (props.x || 0),
y: parentTransform.y + (props.y || 0),
};
for (const child of children) {
drawElement(ctx, child, childTransform);
}
}
ctx.restore();
}
// Main render function
export function render(
element: React.ReactElement,
canvas: HTMLCanvasElement
) {
const ctx = canvas.getContext('2d')!;
// Create container
const container: CanvasContainer = {
canvas,
ctx,
children: [],
width: canvas.width,
height: canvas.height,
draw() {
// Clear canvas
ctx.clearRect(0, 0, this.width, this.height);
// Draw all children
for (const child of this.children) {
drawElement(ctx, child, { x: 0, y: 0 });
}
},
};
// Create root
const root = reconciler.createContainer(
container,
0, // LegacyRoot (use 1 for ConcurrentRoot)
null, // hydrationCallbacks
false, // isStrictMode
null, // concurrentUpdatesByDefaultOverride
'', // identifierPrefix
() => {}, // onRecoverableError
null // transitionCallbacks
);
// Initial render
reconciler.updateContainer(element, root, null, () => {});
// Return unmount function
return {
unmount() {
reconciler.updateContainer(null, root, null, () => {});
},
};
}
// Export element types for JSX
export const Canvas = {
rect: 'rect',
circle: 'circle',
text: 'text',
group: 'group',
} as const;
Using the Canvas Renderer
// App.tsx
import { useState, useEffect } from 'react';
import { render, Canvas } from './canvas-renderer';
// Custom hooks work!
function useAnimation(duration: number) {
const [progress, setProgress] = useState(0);
useEffect(() => {
const start = performance.now();
let rafId: number;
function animate(now: number) {
const elapsed = now - start;
setProgress(Math.min(elapsed / duration, 1));
if (elapsed < duration) {
rafId = requestAnimationFrame(animate);
}
}
rafId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafId);
}, [duration]);
return progress;
}
// React components work!
function AnimatedCircle({ baseX }: { baseX: number }) {
const progress = useAnimation(2000);
const y = 100 + Math.sin(progress * Math.PI * 2) * 50;
return (
<circle
cx={baseX}
cy={y}
radius={20}
fill={`hsl(${progress * 360}, 70%, 50%)`}
/>
);
}
function Button({ x, y, label, onClick }: ButtonProps) {
const [hovered, setHovered] = useState(false);
return (
<group x={x} y={y}>
<rect
x={0}
y={0}
width={100}
height={40}
fill={hovered ? '#4a90d9' : '#3a80c9'}
cornerRadius={8}
/>
<text
x={50}
y={25}
text={label}
fill="white"
fontSize={14}
textAlign="center"
/>
</group>
);
}
function Scene() {
const [count, setCount] = useState(0);
return (
<group>
{/* Background */}
<rect x={0} y={0} width={800} height={600} fill="#1a1a2e" />
{/* Animated circles */}
{[100, 200, 300, 400, 500].map((x, i) => (
<AnimatedCircle key={i} baseX={x} />
))}
{/* Counter display */}
<text
x={400}
y={300}
text={`Count: ${count}`}
fill="white"
fontSize={48}
textAlign="center"
/>
{/* Buttons would need event system */}
<rect
x={350}
y={350}
width={100}
height={40}
fill="#4a90d9"
cornerRadius={8}
/>
</group>
);
}
// Mount to canvas
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const { unmount } = render(<Scene />, canvas);
// Cleanup on HMR
if (import.meta.hot) {
import.meta.hot.dispose(() => unmount());
}
Adding Event Handling
// canvas-renderer/events.ts
import type { CanvasContainer, CanvasElement } from './types';
interface HitArea {
element: CanvasElement;
bounds: { x: number; y: number; width: number; height: number };
}
export function setupEventHandling(container: CanvasContainer) {
const { canvas } = container;
// Build hit test map after each render
function buildHitMap(): HitArea[] {
const hitAreas: HitArea[] = [];
function traverse(
element: CanvasElement,
offsetX: number,
offsetY: number
) {
const { type, props, children } = element;
const x = offsetX + ((props.x as number) || 0);
const y = offsetY + ((props.y as number) || 0);
// Check if element has event handlers
if (props.onClick || props.onMouseEnter || props.onMouseLeave) {
let bounds = { x: 0, y: 0, width: 0, height: 0 };
switch (type) {
case 'rect':
bounds = {
x,
y,
width: props.width as number,
height: props.height as number,
};
break;
case 'circle':
const r = props.radius as number;
bounds = {
x: x - r,
y: y - r,
width: r * 2,
height: r * 2,
};
break;
}
hitAreas.push({ element, bounds });
}
// Traverse children
for (const child of children) {
traverse(child, x, y);
}
}
for (const child of container.children) {
traverse(child, 0, 0);
}
return hitAreas;
}
function hitTest(x: number, y: number): CanvasElement | null {
const hitAreas = buildHitMap();
// Reverse order (top elements first)
for (let i = hitAreas.length - 1; i >= 0; i--) {
const { element, bounds } = hitAreas[i];
if (
x >= bounds.x &&
x <= bounds.x + bounds.width &&
y >= bounds.y &&
y <= bounds.y + bounds.height
) {
return element;
}
}
return null;
}
// Event listeners
let hoveredElement: CanvasElement | null = null;
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const element = hitTest(x, y);
if (element?.props.onClick) {
(element.props.onClick as () => void)();
}
});
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const element = hitTest(x, y);
if (element !== hoveredElement) {
if (hoveredElement?.props.onMouseLeave) {
(hoveredElement.props.onMouseLeave as () => void)();
}
if (element?.props.onMouseEnter) {
(element.props.onMouseEnter as () => void)();
}
hoveredElement = element;
}
canvas.style.cursor = element?.props.onClick ? 'pointer' : 'default';
});
}
Building a Terminal Renderer (like Ink)
// terminal-renderer/hostConfig.ts
import Reconciler from 'react-reconciler';
import { DefaultEventPriority } from 'react-reconciler/constants';
interface TerminalElement {
type: string;
props: Record<string, unknown>;
children: TerminalElement[];
text?: string;
}
interface TerminalContainer {
children: TerminalElement[];
render: () => void;
}
// ANSI color codes
const colors: Record<string, string> = {
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
reset: '\x1b[0m',
};
const styles: Record<string, string> = {
bold: '\x1b[1m',
dim: '\x1b[2m',
italic: '\x1b[3m',
underline: '\x1b[4m',
};
function renderToString(element: TerminalElement, indent = 0): string {
const { type, props, children, text } = element;
let output = '';
switch (type) {
case 'TEXT':
return text || '';
case 'text': {
let prefix = '';
let suffix = colors.reset;
if (props.color && colors[props.color as string]) {
prefix += colors[props.color as string];
}
if (props.bold) prefix += styles.bold;
if (props.dim) prefix += styles.dim;
if (props.italic) prefix += styles.italic;
if (props.underline) prefix += styles.underline;
const content = children.map((c) => renderToString(c)).join('');
return prefix + content + suffix;
}
case 'box': {
const padding = (props.padding as number) || 0;
const marginLeft = (props.marginLeft as number) || 0;
const border = props.border as boolean;
const content = children.map((c) => renderToString(c)).join('');
const lines = content.split('\n');
const width = Math.max(...lines.map((l) => stripAnsi(l).length));
const leftPad = ' '.repeat(marginLeft);
const innerPad = ' '.repeat(padding);
if (border) {
output += leftPad + '┌' + '─'.repeat(width + padding * 2) + '┐\n';
for (const line of lines) {
const stripped = stripAnsi(line);
const rightPad = ' '.repeat(width - stripped.length);
output += leftPad + '│' + innerPad + line + rightPad + innerPad + '│\n';
}
output += leftPad + '└' + '─'.repeat(width + padding * 2) + '┘';
} else {
for (const line of lines) {
output += leftPad + innerPad + line + '\n';
}
}
return output;
}
case 'newline':
return '\n';
default:
return children.map((c) => renderToString(c)).join('');
}
}
function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
const hostConfig: Reconciler.HostConfig<...> = {
createInstance(type, props) {
return { type, props, children: [] };
},
createTextInstance(text) {
return { type: 'TEXT', props: {}, children: [], text };
},
appendChildToContainer(container, child) {
container.children.push(child);
},
appendChild(parent, child) {
parent.children.push(child);
},
// ... other methods similar to canvas
resetAfterCommit(container) {
container.render();
},
// Terminal supports text content
shouldSetTextContent: () => false,
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
isPrimaryRenderer: true,
getCurrentEventPriority: () => DefaultEventPriority,
// ... other required methods
};
export function render(element: React.ReactElement) {
const container: TerminalContainer = {
children: [],
render() {
// Clear screen
process.stdout.write('\x1b[2J\x1b[H');
// Render tree
for (const child of this.children) {
process.stdout.write(renderToString(child));
}
process.stdout.write('\n');
},
};
const reconciler = Reconciler(hostConfig);
const root = reconciler.createContainer(container, 0, null, false, null, '', () => {}, null);
reconciler.updateContainer(element, root, null, () => {});
return {
rerender(newElement: React.ReactElement) {
reconciler.updateContainer(newElement, root, null, () => {});
},
unmount() {
reconciler.updateContainer(null, root, null, () => {});
},
};
}
What Building a Renderer Teaches You
1. How React's Update Cycle Actually Works
┌─────────────────────────────────────────────────────────────────────────────┐
│ REACT UPDATE CYCLE (DEEP VIEW) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ setState() / useState setter called │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ SCHEDULE PHASE │ │
│ │ • Create update object │ │
│ │ • Enqueue on fiber's updateQueue │ │
│ │ • Schedule work via scheduler │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (when scheduler gives time slice) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ RENDER PHASE (Interruptible!) │ │
│ │ • beginWork(): Process fiber, call function/render │ │
│ │ • reconcileChildren(): Diff new vs old children │ │
│ │ • YOUR hostConfig.createInstance() called here │ │
│ │ • YOUR hostConfig.prepareUpdate() called here │ │
│ │ • completeWork(): Bubble up side effects │ │
│ │ │ │
│ │ Can be interrupted! Work is NOT visible yet. │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (render complete) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ COMMIT PHASE (Synchronous, cannot interrupt!) │ │
│ │ │ │
│ │ Before Mutation: │ │
│ │ • getSnapshotBeforeUpdate │ │
│ │ • YOUR hostConfig.prepareForCommit() called │ │
│ │ │ │
│ │ Mutation: │ │
│ │ • YOUR hostConfig.appendChild/removeChild called │ │
│ │ • YOUR hostConfig.commitUpdate() called │ │
│ │ • DOM mutations happen │ │
│ │ │ │
│ │ Layout: │ │
│ │ • useLayoutEffect callbacks run │ │
│ │ • componentDidMount/Update │ │
│ │ • YOUR hostConfig.resetAfterCommit() called │ │
│ │ │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (after paint) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ PASSIVE EFFECTS │ │
│ │ • useEffect callbacks run │ │
│ │ • Scheduled asynchronously after browser paint │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
2. Why Certain Patterns Perform Better
// Understanding this helps you optimize
// BAD: Creates new object identity every render
// prepareUpdate() always returns changes → commitUpdate() always called
function Component() {
return <rect style={{ x: 10, y: 20 }} />;
}
// GOOD: Stable reference
const style = { x: 10, y: 20 };
function Component() {
return <rect style={style} />;
}
// Understanding: React compares props with ===
// prepareUpdate in your renderer sees old !== new
// Even if values are same, new object = update scheduled
3. Debugging React Internals
// Access fiber from any element (dev only)
function DebugComponent() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// Get the fiber node
const fiber = (ref.current as any)?._reactFiber;
if (fiber) {
console.log('Fiber:', {
tag: fiber.tag,
type: fiber.type,
memoizedState: fiber.memoizedState,
memoizedProps: fiber.memoizedProps,
flags: fiber.flags,
alternate: fiber.alternate,
});
// Walk up to find parent
let parent = fiber.return;
while (parent) {
console.log('Parent:', parent.type?.name || parent.type);
parent = parent.return;
}
}
}, []);
return <div ref={ref}>Debug me</div>;
}
// Understanding stateNode
// For host components (div, span), stateNode is the DOM node
// For class components, stateNode is the class instance
// For function components, stateNode is null
4. Why Suspense and Concurrent Features Work
// Building a renderer teaches you:
// 1. Render phase is interruptible because it doesn't touch host
// 2. All host mutations happen in commit phase (synchronous)
// 3. Double buffering allows "throwing away" work-in-progress
// This is why Suspense can "pause" rendering:
// - Render phase hits a promise (thrown)
// - Work-in-progress tree is incomplete
// - React can switch to rendering something else
// - When promise resolves, resume building that tree
// - Only commit when tree is complete
// Your prepareUpdate runs during render (can be thrown away)
// Your commitUpdate runs during commit (always completes)
Production Considerations
┌─────────────────────────────────────────────────────────────────────────────┐
│ CUSTOM RENDERER CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Core Implementation │
│ □ All required hostConfig methods implemented │
│ □ Proper instance creation and cleanup │
│ □ Child ordering (appendChild, insertBefore) correct │
│ □ Updates properly diffed and applied │
│ □ Text handling (or explicit error if unsupported) │
│ │
│ Performance │
│ □ prepareUpdate returns null when nothing changed │
│ □ Instance pooling for frequently created elements │
│ □ Batch DOM/canvas operations in resetAfterCommit │
│ □ Avoid allocations in hot paths │
│ │
│ React Compatibility │
│ □ Refs work (getPublicInstance returns correct thing) │
│ □ useLayoutEffect timing correct (commit phase) │
│ □ useEffect timing correct (after resetAfterCommit) │
│ □ Error boundaries catch renderer errors │
│ □ Suspense boundaries work (if supporting concurrent) │
│ │
│ DevTools │
│ □ injectIntoDevTools called with correct config │
│ □ Elements show in React DevTools │
│ □ Props/state inspection works │
│ │
│ Testing │
│ □ Unit tests for hostConfig methods │
│ □ Integration tests for common component patterns │
│ □ Memory leak tests (mount/unmount cycles) │
│ □ Concurrent mode compatibility tests │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
Building a custom React renderer teaches you more about React than any amount of documentation:
-
React is not DOM-bound. The reconciler is a general-purpose diffing and scheduling engine. react-dom is just one implementation.
-
Fibers are the real tree. Components compile down to Fiber nodes with parent/child/sibling links, memoized state, and effect flags.
-
Double buffering enables concurrency. The current tree stays stable while work-in-progress is built. This is why renders can be interrupted.
-
Commit is synchronous. All your hostConfig mutation methods run in a single synchronous pass. This guarantees consistent output.
-
prepareUpdate is render, commitUpdate is commit. Understanding this separation explains why certain patterns are fast and others aren't.
The practical applications are everywhere: custom rendering for games, visualizations, CLI tools, native platforms. But even if you never ship a custom renderer, the knowledge transforms how you debug performance issues, understand React errors, and architect complex applications.
The best React developers understand what happens beneath the component model. Building a renderer is the fastest path to that understanding.
What did you think?