At the foundation of React's architecture lies a fundamental separation of concerns between two independent subsystems:
The react-reconciler package opens access to the Fiber core, allowing engineers to build custom declarative reactive renderers for non-standard execution environments:
htop or k9s.You do not need to re-implement the task scheduler, queue traversal algorithms, or hooks mechanics like useState and useEffect. By implementing the HostConfig contract—consisting of 20+ lifecycle methods (createInstance, appendChild, prepareUpdate, commitUpdate)—you delegate all state management and scheduling to React while focusing strictly on physically drawing nodes on the target platform.
The interaction between user JSX components, the Reconciler core, and the host environment follows a strict pipeline:
┌──────────────────────────────────────────────────────────┐
│ USER JSX CODE │
│ <Stage width={800} height={600}> │
│ <Rectangle x={10} y={20} fill="#3b82f6" /> │
│ <Circle radius={30} fill="#ef4444" /> │
│ </Stage> │
└────────────────────────────┬─────────────────────────────┘
│ React Element Tree (Virtual)
▼
┌──────────────────────────────────────────────────────────┐
│ REACT 19 FIBER RECONCILER │
│ - Concurrent Mode / Transition Scheduler │
│ - Diffing calculation between current & work-in-progress│
│ - Mutation list preparation (Effects / Mutations) │
└────────────────────────────┬─────────────────────────────┘
│ HostConfig method calls
▼
┌──────────────────────────────────────────────────────────┐
│ CUSTOM HOST CONFIG (Renderer Adapter) │
│ createInstance('rectangle', props) │
│ prepareUpdate(instance, type, oldProps, newProps) │
│ commitUpdate(instance, updatePayload, type) │
└────────────────────────────┬─────────────────────────────┘
│ Direct platform commands
▼
┌──────────────────────────────────────────────────────────┐
│ TARGET SCENE GRAPH (Host Environment) │
│ HTML5 Canvas 2D Context | CLI Terminal ANSI Canvas │
└──────────────────────────────────────────────────────────┘The HostConfig contract strictly partitions the lifecycle into two phases:
createInstance, createTextInstance, appendInitialChild, prepareUpdate. These execute in memory on off-screen nodes without modifying the screen. If the render is interrupted by the Scheduler (Concurrent interruption) for a higher-priority task, incomplete data structures are simply garbage-collected without visual artifacts.appendChild, removeChild, commitUpdate, resetAfterCommit. Synchronous application of calculated changes to the physical graph, culminating in final frame rendering.Define base node classes (CanvasNode, Container) with tree hierarchy parent/child relationships and geometric properties.
Implement the createInstance factory, appendInitialChild tree linking, and prepareUpdate props delta calculations.
Initialize reconciler.createContainer with the ConcurrentRoot tag (React 19) and bind it to the native canvas context.
Traverse the shape tree within the resetAfterCommit hook and flush the frame to the 2D Context in under 16 ms.
Declare global TypeScript types for custom intrinsic tags (<rect>, <circle>) with full property autocompletion.
Why is it strictly forbidden to invoke drawing commands (such as ctx.fillRect) inside the createInstance method of HostConfig?
<Candlestick data={bar} />).Before configuring the Reconciler, design an in-memory tree representation of scene nodes:
export type NodeType = 'stage' | 'rect' | 'circle' | 'text';
export interface BaseNodeProps {
x?: number;
y?: number;
[key: string]: unknown;
}
export interface RectProps extends BaseNodeProps {
width: number;
height: number;
fill: string;
}
export interface CircleProps extends BaseNodeProps {
radius: number;
fill: string;
}
export class CanvasNode {
public type: NodeType;
public props: Record<string, unknown>;
public children: CanvasNode[] = [];
public parent: CanvasNode | null = null;
constructor(type: NodeType, props: Record<string, unknown>) {
this.type = type;
this.props = { ...props };
}
public appendChild(child: CanvasNode): void {
child.parent = this;
this.children.push(child);
}
public removeChild(child: CanvasNode): void {
const index = this.children.indexOf(child);
if (index !== -1) {
child.parent = null;
this.children.splice(index, 1);
}
}
public insertBefore(child: CanvasNode, beforeChild: CanvasNode): void {
const index = this.children.indexOf(beforeChild);
if (index !== -1) {
child.parent = this;
this.children.splice(index, 0, child);
} else {
this.appendChild(child);
}
}
}The configuration object is passed to the ReactReconciler(hostConfig) factory:
import ReactReconciler from 'react-reconciler';
import { DefaultEventPriority } from 'react-reconciler/constants';
import { CanvasNode, type NodeType } from './CanvasNode';
export interface CanvasContainer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
rootNode: CanvasNode;
}
export type Instance = CanvasNode;
export type TextInstance = null;
export type SuspenseInstance = null;
export type HydratableInstance = null;
export type PublicInstance = CanvasNode;
export type HostContext = Record<string, unknown>;
export type UpdatePayload = Record<string, unknown> | null;
export const hostConfig: ReactReconciler.HostConfig<
NodeType,
Record<string, unknown>,
CanvasContainer,
Instance,
TextInstance,
SuspenseInstance,
HydratableInstance,
PublicInstance,
HostContext,
UpdatePayload,
unknown,
number,
unknown
> = {
supportsMutation: true,
supportsPersistence: false,
createInstance(type, props) {
return new CanvasNode(type, props);
},
createTextInstance() {
throw new Error('CanvasReconciler: Raw text nodes must be wrapped in a <Text> component');
},
appendInitialChild(parent, child) {
parent.appendChild(child);
},
appendChild(parent, child) {
parent.appendChild(child);
},
appendChildToContainer(container, child) {
container.rootNode.appendChild(child);
},
removeChild(parent, child) {
parent.removeChild(child);
},
removeChildFromContainer(container, child) {
container.rootNode.removeChild(child);
},
insertBefore(parent, child, beforeChild) {
parent.insertBefore(child, beforeChild);
},
insertInContainerBefore(container, child, beforeChild) {
container.rootNode.insertBefore(child, beforeChild);
},
// Calculate props diff during the Render phase
prepareUpdate(instance, type, oldProps, newProps) {
const payload: Record<string, unknown> = {};
let hasChanges = false;
for (const key of Object.keys(newProps)) {
if (newProps[key] !== oldProps[key] && key !== 'children') {
payload[key] = newProps[key];
hasChanges = true;
}
}
return hasChanges ? payload : null;
},
// Atomically apply diff during the Commit phase
commitUpdate(instance, updatePayload) {
if (updatePayload) {
Object.assign(instance.props, updatePayload);
}
},
clearContainer(container) {
container.rootNode.children = [];
},
getRootHostContext() {
return {};
},
getChildHostContext(parentHostContext) {
return parentHostContext;
},
shouldSetTextContent() {
return false;
},
finalizeInitialChildren() {
return false;
},
prepareForCommit() {
return null;
},
resetAfterCommit(container) {
// Synchronization point: re-render the frame onto the Canvas after all mutations are committed
redrawCanvas(container);
},
getCurrentEventPriority() {
return DefaultEventPriority;
},
getInstanceFromNode() {
return null;
},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {
return null;
},
detachDeletedInstance() {},
};
function redrawCanvas(container: CanvasContainer): void {
const { ctx, canvas, rootNode } = container;
ctx.clearRect(0, 0, canvas.width, canvas.height);
function renderSubtree(node: CanvasNode): void {
if (node.type === 'rect') {
ctx.fillStyle = (node.props.fill as string) || '#000';
ctx.fillRect(
(node.props.x as number) || 0,
(node.props.y as number) || 0,
(node.props.width as number) || 50,
(node.props.height as number) || 50
);
} else if (node.type === 'circle') {
ctx.fillStyle = (node.props.fill as string) || '#000';
ctx.beginPath();
ctx.arc(
(node.props.x as number) || 0,
(node.props.y as number) || 0,
(node.props.radius as number) || 20,
0,
Math.PI * 2
);
ctx.fill();
}
for (const child of node.children) {
renderSubtree(child);
}
}
for (const child of rootNode.children) {
renderSubtree(child);
}
}Instantiation of FiberRootNode via Reconciler bound to the native HTML5 Canvas 2D Context.
Fiber constructs the offscreen CanvasNode tree in memory and computes property diffs (x, y, fill).
Reconciler applies calculated diffs to the scene graph without blocking the browser DOM.
The commit completion hook clears the canvas and performs a recursive paint pass over the scene graph.
What should the prepareUpdate method return if a component <Rectangle width={100} fill='blue' /> only changes its fill color to fill='red'?
Below is the production entry point for the custom Canvas renderer CanvasRenderer, implementing the createRoot container API leveraging React 19 Concurrent Root:
import React from 'react';
import ReactReconciler from 'react-reconciler';
import { hostConfig, type CanvasContainer } from './hostConfig';
import { CanvasNode } from './CanvasNode';
const reconciler = ReactReconciler(hostConfig);
/**
* Custom renderer for drawing React components directly onto HTML5 Canvas
*/
export class CanvasRenderer {
private container: CanvasContainer;
private fiberRoot: ReactReconciler.FiberRoot;
private isMounted: boolean = true;
constructor(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('[CanvasRenderer]: Failed to acquire CanvasRenderingContext2D');
}
this.container = {
canvas,
ctx,
rootNode: new CanvasNode('stage', {}),
};
// 0 = LegacyRoot, 1 = ConcurrentRoot (React 19 standard)
const ConcurrentRootTag = 1;
this.fiberRoot = reconciler.createContainer(
this.container,
ConcurrentRootTag,
null,
false,
null,
'',
(error: Error) => {
console.error('[Canvas Reconciler Fatal Error]:', error);
},
null
);
}
/**
* Mounts or updates a JSX tree inside the Canvas container
*/
public render(element: React.ReactNode): void {
if (!this.isMounted) {
throw new Error('[CanvasRenderer]: Cannot call render on an unmounted container');
}
reconciler.updateContainer(element, this.fiberRoot, null, () => {
// Optional callback executed upon initial tree mount
});
}
/**
* Safely unmounts the tree, triggering all useEffect/useLayoutEffect cleanup handlers
*/
public unmount(): void {
if (!this.isMounted) return;
reconciler.updateContainer(null, this.fiberRoot, null, () => {
hostConfig.clearContainer(this.container);
this.container.ctx.clearRect(
0,
0,
this.container.canvas.width,
this.container.canvas.height
);
this.isMounted = false;
});
}
}Executing draw calls (ctx.fillRect(...)) inside createInstance or prepareUpdate. In Concurrent Mode, the Render phase can be paused, split, or restarted. Doing this leads to phantom visual artifacts on screen that do not exist in the committed component tree.
// ❌ Anti-pattern: side effect during the Render phase!
createInstance(type, props) {
ctx.fillRect(props.x, props.y, props.w, props.h); // Draws before commit is confirmed!
return new CanvasNode(type, props);
}
// ✅ Correct solution: create in-memory data structures only.
// Physical drawing occurs strictly inside resetAfterCommit after commit finalization.If prepareUpdate returns true or an empty object rather than an explicit diff dictionary of changed properties, commitUpdate cannot identify which parameters changed, forfeiting the benefits of granular diffing.
// ❌ Anti-pattern: loss of mutation granularity
prepareUpdate() {
return true; // commitUpdate has no record of WHAT changed
}
// ✅ Correct solution: return explicit diff payload
prepareUpdate(instance, type, oldProps, newProps) {
const diff: Record<string, unknown> = {};
if (oldProps.fill !== newProps.fill) diff.fill = newProps.fill;
return Object.keys(diff).length > 0 ? diff : null;
}Nodes within Canvas (such as <Circle /> or <Rectangle />) are virtual scene graph instances of CanvasNode, not browser DOM elements. They cannot support native addEventListener.
Solution: Attach a single click listener to the outer HTML <canvas>, read event.offsetX and event.offsetY, and perform mathematical Hit Testing across the scene graph in reverse Z-index order.
When removing a node in removeChild, you must explicitly clear the back-reference: child.parent = null. Failing to do so creates circular references between scene graph nodes and event handler closures, holding memory in the JS Heap even after component unmounting.
react-reconciler package implements the HostConfig contract, powering custom renderers for Canvas, WebGL, Terminal (CLI), and PDF.createInstance, appendInitialChild, and prepareUpdate execute during the Render phase and must remain strictly free of side effects or direct draw calls.resetAfterCommit synchronization hook.