| |

React 130 ⚛️ Custom React Renderers (react-reconciler)

⏱️ Estimated read: 13 min | |

React is not a web framework. It is a reconciler: an algorithm that diffs a component tree and produces a stream of mutations. The react-dom package is one host of that algorithm, rendering to the browser’s DOM. React Native is another, rendering to native mobile views. A custom renderer is a third, and the react-reconciler package is the public (though unstable) interface that lets a developer define a new host for the same reconciliation engine.

This chapter covers the architecture of a custom renderer. It begins with the host config—the object of imperative methods that the reconciler calls to create, update, and remove instances—and the mutation methods that every renderer must implement. It then addresses the container and the entry point that connects a React tree to the host, the priority and scheduling methods that determine how updates are batched, and the version alignment constraint that makes a custom renderer a maintenance commitment. The final sections cover the real-world examples that prove the model—React Three Fiber, Ink, and React PDF—and the cases where the reconciler is the wrong abstraction.


Key point: A custom renderer does not reimplement React. It implements the host config, and the reconciler does the rest. The host config is a set of imperative methods, and the reconciler calls them in a specific order. The renderer’s job is to make those methods correct, not to understand the fiber tree.


Why react-reconciler exists

The abstraction boundary. The reconciler is the part of React that compares the previous tree to the next tree and computes the minimal set of changes. The host is the part that applies those changes to a specific environment. The react-reconciler package is the boundary: it takes a host config and returns a renderer object with createContainer, updateContainer, and flushSync . The DOM, the native mobile views, and the terminal are all hosts for the same reconciler.

The unstable API warning. The react-reconciler package is not a stable public API. Its documentation states that the internalHandle data structure is opaque and may change significantly between versions. Relying on internal fields is taking on maintenance risk . The package is published, and major projects use it, but the surface is not covered by the same stability guarantees as react and react-dom. A custom renderer is a commitment to tracking React’s internals.

The use cases. The reconciler is the right tool when the output is not the DOM but the React programming model is still valuable. React Three Fiber renders to a three.js scene graph, Ink renders to a terminal, and React PDF renders to a PDF document . The common thread is that the developer wants components, state, and hooks to describe a non-DOM output. The reconciler is the bridge.

The alternative. For many cases, the reconciler is overkill. A component that wraps a canvas and draws imperatively does not need a custom renderer. A component that renders a chart library does not need a custom renderer. The reconciler is for the cases where the component tree itself should be the output’s structure, not a wrapper around an imperative API.


a. The host config: mutation methods and the instance tree

The host config is an object of methods. The reconciler calls them in a specific order to mutate the host tree. The mutation methods are the core: appendChild, insertBefore, removeChild, and their container variants. The appendChild(parentInstance, child) method adds the child to the parent’s list of children. The insertBefore(parentInstance, child, beforeChild) method inserts the child before beforeChild, and it is used both for insertions and for reordering existing nodes . The removeChild(parentInstance, child) method removes the top-level node being removed; the reconciler expects garbage collection to handle the subtree .

// A minimal host config for a renderer that stores nodes in plain objects.
const hostConfig = {
  supportsMutation: true,

  createInstance(type, props, rootContainer, hostContext, internalHandle) {
    // Create a host-specific instance. For a toy renderer,
    // a plain object with a type and children array.
    return { type, props, children: [] };
  },

  createTextInstance(text, rootContainer, hostContext, internalHandle) {
    return { type: '#text', text };
  },

  appendChild(parentInstance, child) {
    parentInstance.children.push(child);
  },

  insertBefore(parentInstance, child, beforeChild) {
    const index = parentInstance.children.indexOf(beforeChild);
    parentInstance.children.splice(index, 0, child);
  },

  removeChild(parentInstance, child) {
    const index = parentInstance.children.indexOf(child);
    if (index !== -1) parentInstance.children.splice(index, 1);
  },

  appendChildToContainer(container, child) {
    container.children.push(child);
  },

  removeChildFromContainer(container, child) {
    const index = container.children.indexOf(child);
    if (index !== -1) container.children.splice(index, 1);
  },

  commitUpdate(instance, updatePayload, type, prevProps, nextProps, internalHandle) {
    // Apply the prop changes to the host instance.
    instance.props = nextProps;
  },

  commitTextUpdate(textInstance, prevText, nextText) {
    textInstance.text = nextText;
  },

  getPublicInstance(instance) {
    // What ref returns. Return the instance itself by default.
    return instance;
  },
};

The createInstance method is where the host-specific node is constructed. The type is the JSX tag name (e.g., 'mesh' for R3F, 'div' for DOM), the props are the JSX attributes, and the internalHandle is an opaque data structure that React passes through . The hostContext parameter carries information about the tree’s position, which the DOM renderer uses to distinguish HTML from SVG .

The getPublicInstance method determines what a ref points to. For most renderers, returning the instance itself is correct. For a renderer that wraps a complex object, returning a subset is an option .


b. The container, the entry point, and the priority methods

The createContainer method returns a container object that represents the root of the rendered tree. The updateContainer method takes an element and a container and schedules the render. The pattern is the same as ReactDOM.createRoot and root.render, but the container is host-defined .

import Reconciler from 'react-reconciler';

const MyRenderer = Reconciler(hostConfig);

function createRoot(container) {
  const root = MyRenderer.createContainer(
    container,
    0,        // tag
    null,     // hydrationCallbacks
    false,    // isStrictMode
    null,     // concurrentUpdatesByDefaultOverride
    '',       // identifierPrefix
    (onRecoverableError) => console.error(onRecoverableError),
    null      // transitionCallbacks
  );
  return root;
}

function render(element, root) {
  MyRenderer.updateContainer(element, root, null, () => {
    // Optional callback after the update.
  });
}

The getCurrentEventPriority method tells the reconciler how urgent the current update is. The react-reconciler/constants entry point exports DiscreteEventPriority, ContinuousEventPriority, and DefaultEventPriority . A discrete event (a click) should interrupt background work and cannot be batched across time. A continuous event (a mouseover) should interrupt but can be batched. The default priority is for background work. A renderer that does not have user events can return DefaultEventPriority unconditionally.

The scheduling methods—scheduleTimeout, cancelTimeout, noTimeout, scheduleMicrotask, and supportsMicrotask—are the host’s timer implementation. For a browser or Node environment, they proxy to setTimeout, clearTimeout, and queueMicrotask. For a platform with different scheduling primitives, they are the customization point . The isPrimaryRenderer property should be true if this is the main renderer on the page, and false if it coexists with another renderer.


c. Version alignment and the maintenance commitment

The most common failure in custom renderers is version skew. The react-reconciler package is versioned in lockstep with React, and the host config it expects changes between versions. If the application’s react version is newer than the renderer’s react-reconciler version, the reconciler may emit fiber tags that the host config’s feature flags do not expect, producing an error like “Invalid host parent fiber” . The fix is to dedupe react, react-dom, and react-reconciler to a single version, or to upgrade the custom renderer package so its reconciler matches the application’s React version.

The react-reconciler source has a deliberate trap: ReactFiberConfig.js is a module that must be shimmed by the renderer’s host config. If a build configuration imports the reconciler source without aliasing that module, it throws at import time . The published npm build ships with a bundled config, so consuming the package normally avoids the trap. Forking or deep-importing the source requires replicating React’s own aliasing setup.

The commitment is real. A custom renderer is not a one-time implementation; it is a dependency on React’s internals that must be maintained across React versions. The projects that use it successfully—React Three Fiber, Ink, React PDF—have dedicated maintainers who track the changes. For a team that needs a custom renderer, the decision is whether the output justifies the maintenance, and whether the team is prepared to track React’s releases.


Complete Example Session

// ============================================
// PART 1: THE HOST CONFIG SKELETON
// ============================================
// The object the reconciler calls.

const hostConfig = {
  supportsMutation: true,
  isPrimaryRenderer: false,
  noTimeout: -1,
  // ... methods below
};

// ============================================
// PART 2: THE INSTANCE CREATION
// ============================================
// createInstance and createTextInstance.

createInstance(type, props) {
  return { type, props, children: [] };
},
createTextInstance(text) {
  return { type: '#text', text };
},

// ============================================
// PART 3: THE MUTATION METHODS
// ============================================
// appendChild, insertBefore, removeChild.

appendChild(parent, child) {
  parent.children.push(child);
},
insertBefore(parent, child, before) {
  const i = parent.children.indexOf(before);
  parent.children.splice(i, 0, child);
},
removeChild(parent, child) {
  const i = parent.children.indexOf(child);
  if (i !== -1) parent.children.splice(i, 1);
},

// ============================================
// PART 4: THE CONTAINER VARIANTS
// ============================================
// Same as above, for the root container.

appendChildToContainer(container, child) {
  container.children.push(child);
},
removeChildFromContainer(container, child) {
  const i = container.children.indexOf(child);
  if (i !== -1) container.children.splice(i, 1);
},

// ============================================
// PART 5: THE UPDATE METHODS
// ============================================
// commitUpdate applies prop changes.

commitUpdate(instance, updatePayload, type, prev, next) {
  instance.props = next;
},
commitTextUpdate(text, prev, next) {
  text.text = next;
},

// ============================================
// PART 6: THE PUBLIC INSTANCE
// ============================================
// What ref returns.

getPublicInstance(instance) {
  return instance;
},

// ============================================
// PART 7: THE PRIORITY METHODS
// ============================================
// getCurrentEventPriority from constants.

const { DefaultEventPriority } = require('react-reconciler/constants');

getCurrentEventPriority() {
  return DefaultEventPriority;
},

// ============================================
// PART 8: THE SCHEDULING METHODS
// ============================================
// Proxy to the host's timers.

scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
supportsMicrotask: true,
scheduleMicrotask: queueMicrotask,

// ============================================
// PART 9: THE RENDERER AND ROOT
// ============================================
// Create the renderer and the container.

const Reconciler = require('react-reconciler');
const renderer = Reconciler(hostConfig);

function createRoot(container) {
  return renderer.createContainer(container, 0, null, false, null, '', console.error, null);
}

function render(element, root) {
  renderer.updateContainer(element, root, null, null);
}

// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// The host config is the imperative layer.
// The reconciler is the declarative engine.
// The version alignment is the maintenance cost.

module.exports = { createRoot, render };

The ten parts build a minimal host config and the entry point that connects it to the reconciler. The pattern is consistent: the host config implements the mutation methods, and the reconciler calls them in the order it determines. The renderer does not need to know how the fiber tree works; it needs to make the host mutations correct.


Quick Reference

Host Config Methods

MethodPurpose
createInstanceCreate a host node
createTextInstanceCreate a text node
appendChildAdd child to parent
insertBeforeInsert or reorder child
removeChildRemove top-level child
commitUpdateApply prop changes
commitTextUpdateUpdate text content
getPublicInstanceWhat ref returns

Container Methods

MethodPurpose
appendChildToContainerAdd child to root
removeChildFromContainerRemove child from root
insertInContainerBeforeInsert before root child

Priority Constants

ConstantMeaning
DiscreteEventPriorityClick, keydown (interrupt, no batch)
ContinuousEventPriorityMouseover (interrupt, batch)
DefaultEventPriorityBackground work

Scheduling Properties

PropertyPurpose
scheduleTimeoutProxy to setTimeout
cancelTimeoutProxy to clearTimeout
noTimeoutSentinel for no timeout
supportsMicrotaskWhether scheduleMicrotask is supported
isPrimaryRendererMain renderer on the page

Best Practices

✅ Do This:

// Implement the container variants.
appendChildToContainer(container, child) { ... }                      // ✅
// Return DefaultEventPriority if there are no events.
getCurrentEventPriority() { return DefaultEventPriority; }            // ✅
// Align react-reconciler with react.
npm ls react react-reconciler                                          // ✅
// Use the published npm build, not the source.
const Reconciler = require('react-reconciler');                       // ✅
// Set isPrimaryRenderer false if coexisting with React DOM.
isPrimaryRenderer: false                                               // ✅

❌ Don’t Do This:

// Don't rely on internalHandle fields.
internalHandle.foo; // opaque, may change                              // ❌
// Don't deep-import the reconciler source without aliasing.
require('react-reconciler/src/...'); // trap module                   // ❌
// Don't traverse the subtree in removeChild.
removeChild(parent, child) { child.children.forEach(...); } // not needed // ❌
// Don't mismatch react and react-reconciler versions.
// "Invalid host parent fiber" may follow.                            // ❌

Common Pitfalls

PitfallWhy It HappensFix
“Invalid host parent fiber”React and reconciler versions skewDedupe versions with overrides
“must be shimmed”Source imported without aliasUse the npm build
Ref returns nothinggetPublicInstance missingReturn the instance
Reordering brokeninsertBefore only appendsSplice at the correct index
Updates ignoredcommitUpdate not applying propsAssign the new props

Real-World Examples

1. React Three Fiber

// Renders JSX to a three.js scene graph.
// The host config creates THREE.Mesh, THREE.Group, etc.

2. Ink

// Renders JSX to a terminal UI.
// The host config creates terminal nodes (Box, Text).

3. React PDF

// Renders JSX to a PDF document.
// The host config creates PDF primitives.

4. createInstance

createInstance(type, props) { return { type, props, children: [] }; }

5. appendChild

appendChild(parent, child) { parent.children.push(child); }

6. insertBefore

const i = parent.children.indexOf(before); parent.children.splice(i, 0, child);

7. removeChild

const i = parent.children.indexOf(child); parent.children.splice(i, 1);

8. getPublicInstance

getPublicInstance(instance) { return instance; }

9. createContainer

renderer.createContainer(container, 0, null, false, null, '', console.error, null);

10. updateContainer

renderer.updateContainer(element, root, null, null);

Visual

The Reconciler and the Host

┌─────────────────────────────────────────────────────────┐
│  REACT COMPONENTS                                       │
│  <App> → <mesh> → <boxGeometry>                         │
│         │                                               │
│         ▼                                               │
│  RECONCILER (react-reconciler)                          │
│  diffs trees, computes mutations                        │
│         │                                               │
│         ▼  calls host config methods                    │
│  HOST CONFIG                                            │
│  createInstance, appendChild, commitUpdate              │
│         │                                               │
│         ▼                                               │
│  HOST TREE (three.js scene, terminal, PDF)              │
└─────────────────────────────────────────────────────────┘

The Host Config Method Order

┌─────────────────────────────────────────────────────────┐
│  MOUNT                                                  │
│  1. createInstance(type, props)                         │
│  2. createInstance(child type, child props)             │
│  3. appendChild(parent, child)                          │
│  4. appendChildToContainer(container, root)             │
│                                                         │
│  UPDATE                                                 │
│  1. commitUpdate(instance, props)                       │
│  2. insertBefore(parent, child, before)  (reorder)      │
│  3. removeChild(parent, child)                          │
│                                                         │
│  The reconciler decides the order.                      │
│  The host config executes the mutations.                │
└─────────────────────────────────────────────────────────┘

The Version Alignment

┌─────────────────────────────────────────────────────────┐
│  package.json                                           │
│                                                         │
│  "react": "19.0.0"                                      │
│  "react-reconciler": "0.31.0"  ← must match React      │
│                                                         │
│  If they diverge:                                       │
│  → fiber tags mismatch                                  │
│  → "Invalid host parent fiber"                          │
│  → runtime crash                                        │
│                                                         │
│  Fix: npm overrides / resolutions to one version        │
└─────────────────────────────────────────────────────────┘

The Real-World Renderers

┌─────────────────────────────────────────────────────────┐
│  REACT DOM          → browser DOM                       │
│  REACT NATIVE       → iOS/Android views                 │
│  REACT THREE FIBER  → three.js scene graph              │
│  INK                → terminal output                   │
│  REACT PDF          → PDF document                      │
│                                                         │
│  All use the same reconciler.                           │
│  All implement a host config.                           │
│  The output is the only difference.                     │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Packagereact-reconciler (unstable)
Core conceptHost config for the mutation methods
Key methodscreateInstance, appendChild, commitUpdate
Container methodsappendChildToContainer, removeChildFromContainer
PrioritygetCurrentEventPriority from constants
SchedulingscheduleTimeout, cancelTimeout, noTimeout
Version alignmentreact-reconciler must match react
Real examplesReact Three Fiber, Ink, React PDF
StabilityOpaque internals, maintenance risk

Key takeaways:

  • A custom renderer implements the host config, not the reconciler. The reconciler is the diffing engine; the host config is the imperative layer that applies the changes. The renderer’s job is to make the host config correct .
  • The mutation methods are the core. createInstance constructs the host node, appendChild and insertBefore place it, and removeChild removes it. The reconciler calls them in the order it determines.
  • insertBefore handles both insertion and reordering. The method must place the child at the correct position, not just append it. React uses it to move existing nodes.
  • The container variants are separate. appendChildToContainer and removeChildFromContainer are for the root container, which may have different semantics from the interior tree.
  • The internalHandle is opaque. Reading or writing its fields is taking on maintenance risk. The published npm build is the supported surface.
  • Version alignment is the maintenance cost. The react-reconciler version must match the react version. A mismatch produces fiber tag errors and runtime crashes. Dedupe with npm overrides, and keep the renderer’s reconciler in sync with React’s releases .
  • The real-world renderers are the proof. React Three Fiber, Ink, and React PDF all use the same reconciler with different host configs. The model works, and the output is the only difference.

Remember: React is not the DOM. It is a reconciler that diffs a component tree and calls a host config to apply the changes. The react-reconciler package is the boundary, and the host config is the renderer’s implementation. The host config is a set of imperative methods, and the reconciler calls them in the order it determines. The renderer does not need to understand the fiber tree; it needs to make the mutations correct. The version alignment is the commitment, and the real-world renderers are the proof that the commitment is worth making when the output justifies it.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!