Most React developers have a mental model that looks like this: React → DOM. That’s the happy path. But React’s actual architecture is a two-layer system: the reconciler (which manages component trees and diffs) and the renderer (which applies those diffs to some host environment). The DOM is just one possible target.
Why Build a Custom Renderer?
The honest answer: you probably don’t need to. But the process of building one reveals things about React’s internals that no amount of reading documentation will give you.
Practical reasons you might:
- Ink — renders React to the terminal (and it’s actually great)
- React Three Fiber — renders React to Three.js scene graph
- React PDF — generates PDFs from JSX
- Testing — full control over the host environment
The react-reconciler Package
React exposes its reconciliation algorithm through a package: react-reconciler. You implement a “host config” object that tells the reconciler how to interact with your target environment.
import ReactReconciler from 'react-reconciler';
const hostConfig = {
// Create an instance of a host element
createInstance(type, props, rootContainer, hostContext, internalHandle) {
return createElement(type, props);
},
// Create text nodes
createTextInstance(text, rootContainer, hostContext, internalHandle) {
return createTextNode(text);
},
// Append a child to a parent
appendChildToContainer(container, child) {
container.appendChild(child);
},
// Called after all children are mounted
finalizeInitialChildren(instance, type, props, rootContainer, hostContext) {
return false; // return true to trigger commitMount
},
// Required but can be empty for simple renderers
prepareUpdate() { return null; },
commitUpdate() {},
// Scheduler (use React's default)
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
// Feature flags
supportsMutation: true,
supportsHydration: false,
isPrimaryRenderer: true,
};
const reconciler = ReactReconciler(hostConfig);
Building the Terminal Renderer
Our target: render React components to a terminal using ANSI escape codes. Each “DOM node” is a rectangular box with position, size, and content.
interface TerminalBox {
type: 'box' | 'text';
x: number;
y: number;
width: number;
height: number;
content?: string;
color?: string;
children: TerminalBox[];
}
The Update Lifecycle
Here’s the part that trips people up. React’s commit phase has three sub-phases:
- Before Mutation —
getSnapshotBeforeUpdateequivalent - Mutation — where DOM changes actually happen
- Layout —
componentDidMount/useLayoutEffect
Each phase calls different host config methods. The reconciler guarantees that children are committed before parents in the mutation phase.
// These fire in order:
prepareForCommit(container)
// ... mutation phase ...
commitMount(instance, type, newProps, ...) // if finalizeInitialChildren returned true
// ... layout phase ...
resetAfterCommit(container)
Concurrent Mode Complications
What I Learned
Building a custom renderer demystifies React’s “magic” completely. The reconciler is a sophisticated tree diffing algorithm with a well-defined interface. Everything else — hooks, concurrent features, Suspense — builds on top of that interface.
The fiber architecture becomes obvious when you’re on this side: each fiber is a unit of work that can be paused, abandoned, or restarted. The “double buffering” (work-in-progress tree vs. current tree) is why React can show consistent UI while working in the background.
If you want to go deeper, here’s a recommended learning path:
- Read the react-reconciler source
Start with the entry point and trace how the host config methods are invoked. The package is well-organized — each commit phase sub-phase lives in its own file.
- Trace a single setState call end-to-end
Follow one
setStatefrom the call site through scheduling, reconciliation, and commit. It’s dense, but it’s one of the most educational things you can do as a React developer. - Study an existing custom renderer
Ink (terminal renderer) is well-maintained and has clean, readable host config code. Reading it alongside the reconciler source makes both much clearer.
- Build your own minimal renderer
Start with a renderer that targets a plain JavaScript object tree. No display logic — just prove you can mount, update, and unmount components. Add complexity incrementally.