跪拜 Guibai
← Back to the summary

How React's Reconciler/Renderer Split Runs the Same Code Across Web, Native, and Test

One Protocol, Multi-Platform Execution — The Multi-Platform Architecture of Reconciler / Renderer Separation

I once worked on an online poster editor—users dragged elements, changed text, and adjusted colors in the browser, finally exporting a PNG. The business was running well, but the boss had a new idea.

"We need a WeChat Mini Program version," he said at the weekly meeting.

I calculated the workload. The poster editor's frontend had nearly two hundred components—canvas, layer panel, property panel, text editor, image cropper... These components were all written based on React + DOM. Mini Programs have no DOM, no div and span, only view and text. Two hundred components, each needing a rewrite.

I said, "It will probably take six months."

The boss said, "You have three months."

Three months passed, and we barely shipped an MVP. But the nightmare was just beginning. Users reported inconsistent features between the web and Mini Program versions—filters available on the web were missing in the Mini Program; a certain animation in the Mini Program wasn't on the web. Every time a new feature was added to the web version, we had to evaluate "should we sync it to the Mini Program?" Syncing meant double the workload; not syncing meant user complaints.

Even more absurdly, the boss later said, "We also need a desktop version." Using Electron. Electron has a DOM, so it seemed like we could directly reuse the web version's code. But the problem was—the communication model between Electron's renderer process and main process is completely different from the browser, and file system access, printing, PDF export... all these capabilities needed re-encapsulation.

At that moment, I finally understood a painful truth: our component logic and rendering platform were tightly coupled together. Two hundred components, each knowing it was running in a browser, each directly calling document.createElement and addEventListener. When it was time to switch platforms, there was no abstraction layer to rely on; we could only rebuild from the ground up.

Later, I spent a long time looking at packages/react-reconciler/src/ReactFiberConfig.js in React's source code. That file is only twenty lines long, with just one useful line of code:

throw new Error('This module must be shimmed by a specific renderer.');

A single throw, hiding behind it a core architectural judgment: completely separate "how to update components" from "how to operate the platform". This is the essence of Reconciler / Renderer separation—one protocol, multi-platform execution.


1. When Component Logic and Rendering Platform Are Welded Together

The problem with the poster editor above is essentially a platform coupling problem. Inside our two hundred React components, the <Canvas /> component directly called canvas.getContext('2d'), <TextEditor /> directly used contentEditable and document.execCommand, and <LayerPanel /> relied on CSS Flexbox drag-and-drop sorting. This code ran fine in the browser, but it was welded to the DOM.

The problems with this pattern aren't obvious when there's only one platform. But once you need to support multiple platforms, the pain multiplies exponentially:

First, code duplication. The same component logic, written once for the browser, once for the Mini Program, once for the desktop. Not the "copy-paste" kind of duplication—it's the more insidious, more expensive duplication of "implementing the same functionality with different APIs."

Second, behavioral inconsistency. Three platforms, three implementations, three sets of bugs. A user reports "the text editor in the Mini Program has a problem." After fixing the Mini Program's, you find the web has a similar problem—but the code is completely different, and the fix can't be reused.

Third, feature misalignment. A new filter is added to the web version, and you need to evaluate "does the Mini Program Canvas support this blend mode?" If not, Mini Program users can't use it this version. If yes, it requires an extra two weeks of development. Product decisions are held hostage by technical constraints.

Fourth, test explosion. Three platforms, three sets of test cases. Changing one piece of common logic requires running three sets of tests. CI time goes from 10 minutes to 30 minutes, then to an hour.

The root cause is the architectural lack of a platform abstraction layer. Components directly interact with platform APIs, rather than indirectly accessing them through an intermediary layer. React didn't make this mistake. From day one, React separated "how components update" and "how update results are drawn on screen" into two independent layers.


2. Reconciler is the Brain, Renderer is the Hands

React's architecture can be roughly split into two halves:

The contract between them is HostConfig. When the Reconciler needs to operate on the platform, it calls functions in HostConfig, rather than directly manipulating the DOM or Native API.

This set of interfaces roughly includes:

Function Role DOM Implementation Native Implementation
createInstance(type, props) Create platform element document.createElement(type) UIManager.createView(tag, class, props)
createTextInstance(text) Create text node document.createTextNode(text) UIManager.createView(tag, RCTText, {text})
appendChild(parent, child) Add child node parent.appendChild(child) UIManager.manageChildren(tag, [], [child])
insertBefore(parent, child, before) Insert child node parent.insertBefore(child, before) UIManager.manageChildren(tag, [], [child], [index])
removeChild(parent, child) Remove child node parent.removeChild(child) UIManager.manageChildren(tag, [child], [])
commitUpdate(instance, updatePayload) Update attributes node.setAttribute(key, val) UIManager.updateView(tag, props)
finalizeInitialChildren() Initialization complete Bind event listeners No-op

The Reconciler never directly calls document.createElement. It calls createInstance—this function is provided by the renderer. If the renderer is for the browser, createInstance internally calls document.createElement. If the renderer is for React Native, createInstance internally calls UIManager.createView.

The same reconciler brain, swapping in a different pair of hands, can work on different platforms.


3. The Protocol and Implementation in the Source Code

3.1 ReactFiberConfig.js — One throw, One Contract

Open packages/react-reconciler/src/ReactFiberConfig.js:

// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberConfig.js
/**
 * We expect that our Rollup, Jest, and Flow configurations
 * always shim this module with the corresponding host config
 * (either provided by a renderer, or a generic shim for npm).
 *
 * We should never resolve to this file, but it exists to make
 * sure that if we *do* accidentally break the configuration,
 * the failure isn't silent.
 */

throw new Error('This module must be shimmed by a specific renderer.');

Twenty lines of code, ninety percent comments. The only useful code is that throw.

But it's precisely this throw that defines the contractual boundary of the entire architecture. In the Reconciler package's source code, every place that needs to operate on the platform imports from this module:

import {
  createInstance,
  appendChild,
  removeChild,
  commitUpdate,
  // ...
} from './ReactFiberConfig';

Note the path—'./ReactFiberConfig', not '../react-dom-bindings/...'. The Reconciler doesn't depend on any specific renderer. It depends on an abstract interface.

The "concrete implementation" of this interface is injected at build time via Rollup's module aliases. Look at react-dom's build configuration: all imports of react-reconciler/src/ReactFiberConfig are redirected to react-dom-bindings/src/client/ReactFiberConfigDOM.js. Meanwhile, react-native-renderer's build configuration redirects the same imports to its own internal Native HostConfig.

This shows: the same reconciler source code, when compiled into the react-dom package, becomes a version that operates on the DOM; when compiled into the react-native-renderer package, becomes a version that operates on Native views. The code is the same, only the interface implementation linked at build time differs.

3.2 ReactFiberConfigDOM.js — A 6669-Line Encyclopedia of DOM Operations

If ReactFiberConfig.js is a "protocol manifesto," then ReactFiberConfigDOM.js is the protocol's "complete implementation." 6669 lines of code, almost the entirety of the React DOM renderer's platform-related logic.

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): Instance {
  const ownerDocument = getOwnerDocumentFromRootContainer(
    rootContainerInstance,
  );
  const domElement: Instance = ownerDocument.createElement(type);
  // ... attribute handling, event binding, ref association
  precacheFiberNode(internalInstanceHandle, domElement);
  updateFiberProps(domElement, props);
  return domElement;
}

export function createTextInstance(
  text: string,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): TextInstance {
  const ownerDocument = getOwnerDocumentFromRootContainer(
    rootContainerInstance,
  );
  const textNode: TextInstance = ownerDocument.createTextNode(text);
  precacheFiberNode(internalInstanceHandle, textNode);
  return textNode;
}

export function appendChild(parentInstance: Instance, child: Instance | TextInstance): void {
  parentInstance.appendChild(child);
}

export function insertBefore(
  parentInstance: Instance,
  child: Instance | TextInstance,
  beforeChild: Instance | TextInstance,
): void {
  parentInstance.insertBefore(child, beforeChild);
}

export function removeChild(
  parentInstance: Instance,
  child: Instance | TextInstance,
): void {
  parentInstance.removeChild(child);
}

export function commitUpdate(
  domElement: Instance,
  updatePayload: Array<mixed>,
  type: string,
  oldProps: Props,
  newProps: Props,
  internalInstanceHandle: Object,
): void {
  // Apply attribute differences to the DOM node
  updateProperties(domElement, updatePayload, type, oldProps, newProps);
  // Update cached props on the Fiber node
  updateFiberProps(domElement, newProps);
}

These functions look simple—aren't they just wrapping DOM APIs? But the devil is in the details.

precacheFiberNode inside createInstance. This function caches the mapping between Fiber nodes and DOM nodes into a global Map. When React needs to "find the corresponding Fiber node from a DOM event" (like event delegation), it doesn't need to traverse the Fiber tree—it directly looks it up from the Map. The management of this Map is entirely done at the HostConfig layer; the reconciler doesn't worry about it.

updateProperties inside commitUpdate. DOM attribute updates aren't simple element.setAttribute. Different attributes have different update logic—style needs CSS string parsing, checked and value need special handling for consistency, event listeners need to be delegated to the root node rather than directly bound. All this DOM-specific complexity is encapsulated within HostConfig. The Reconciler just needs to say "update this node's attributes"; how exactly to update is decided by HostConfig.

Of these 6669 lines of code, only about 5% are functions like the ones above that "directly proxy DOM APIs." The remaining 95% are DOM-specific complex logic—event system, attribute handling, hydration, form element special behaviors, resource preloading, accessibility attributes... All these platform-specific details are swallowed by HostConfig, completely unknown to the reconciler.

3.3 ReactFiberConfigNoop.js — The Art of Capability Composition

If ReactFiberConfigDOM.js is the paradigm of a "full-featured renderer," then ReactFiberConfigNoop.js is the ingenious design of "composing capabilities on demand."

The Noop renderer is a renderer used internally by React for testing. It doesn't operate on any real platform—no DOM, no Native views, all operations happen in memory. But test scenarios have different needs: sometimes they need to simulate mutation (insert/delete/update), sometimes persistence (snapshot/restore), sometimes hydration, sometimes not.

React's approach isn't to write a large, all-encompassing Noop Config, but to break it down into multiple capability modules:

// https://github.com/facebook/react/blob/main/packages/react-noop-renderer/src/ReactFiberConfigNoop.js
export * from './ReactFiberConfigNoopHydration';
export * from './ReactFiberConfigNoopScopes';
export * from './ReactFiberConfigNoopTestSelectors';
export * from './ReactFiberConfigNoopResources';
export * from './ReactFiberConfigNoopSingletons';
export * from './ReactFiberConfigNoopNoMutation';
export * from './ReactFiberConfigNoopNoPersistence';

export type HostContext = Object;
export type TextInstance = { text: string, id: number, ... };
export type Instance = { type: string, id: number, ... };
export type Container = { rootID: string, children: Array<...>, ... };

Look at these file names:

Module Function
ReactFiberConfigNoopHydration.js Hydration capability (client-side activation after SSR)
ReactFiberConfigNoopScopes.js Scope API support
ReactFiberConfigNoopTestSelectors.js Test selector API
ReactFiberConfigNoopResources.js Resource preloading (link preload/prefetch)
ReactFiberConfigNoopSingletons.js Singleton mode (HTML/HEAD/BODY)
ReactFiberConfigNoopNoMutation.js Does not support mutation—empty implementation
ReactFiberConfigNoopNoPersistence.js Does not support persistence—empty implementation

The main file combines the capabilities of all modules via export *. If you need to create a test renderer that "supports mutation but not persistence," createReactNoop.js overrides specific exports:

// https://github.com/facebook/react/blob/main/packages/react-noop-renderer/src/createReactNoop.js
// Override NoMutation's exports, replacing them with a version that actually supports mutation
Object.assign(fiberConfig, mutationConfig);
// Override NoPersistence's exports, replacing them with a version that actually supports persistence (if needed)
if (usePersistentMode) {
  Object.assign(fiberConfig, persistentConfig);
}

This is a design pattern of capability composition. Each capability is an independent module, and the renderer declares what it supports and doesn't support by selectively importing and overriding these modules.

3.4 ReactFiberConfigWithNoMutation.js — How to Express Unsupported Capabilities

In the react-reconciler/src/ directory, there is a set of ReactFiberConfigWithNo*.js files:

ReactFiberConfigWithNoHydration.js
ReactFiberConfigWithNoMicrotasks.js
ReactFiberConfigWithNoMutation.js
ReactFiberConfigWithNoPersistence.js
ReactFiberConfigWithNoResources.js
ReactFiberConfigWithNoScopes.js
ReactFiberConfigWithNoSingletons.js
ReactFiberConfigWithNoTestSelectors.js
ReactFiberConfigWithNoViewTransition.js

Look at the contents of ReactFiberConfigWithNoMutation.js:

// https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
// Renderers that don't support mutation can re-export everything from this module.

function shim(...args: any): empty {
  throw new Error(
    'The current renderer does not support mutation. ' +
      'This error is likely caused by a bug in React. ' +
      'Please file an issue.',
  );
}

export const supportsMutation = false;
export const appendChild = shim;
export const removeChild = shim;
export const commitUpdate = shim;
// ... more mutation functions are all shim

This is an application of the Null Object Pattern. When a platform doesn't support mutation (like some purely declarative rendering targets), the reconciler won't call these functions—because it checks the supportsMutation flag. But if there's a bug in the code path and these functions are accidentally called, shim immediately throws a clear error, rather than silently failing or producing undefined behavior.

This design reflects an engineering judgment by the React team: unsupported features should not be expressed by "not exporting," but by "exporting but marking as unsupported." Because "not exporting" leads to undefined on import, and the error message when undefined is called is extremely obscure. The shim function's clear error message can save hours of debugging time.

Meanwhile, the supportsMutation = false flag allows the reconciler to detect platform capabilities at runtime. If the renderer doesn't support mutation, the reconciler will choose the persistence path (create a new tree first, then replace the whole thing). This is like a smart housekeeper—if there's no mop (mutation) at home, it will use a vacuum cleaner (persistence) to clean.

3.5 ReactDOMRoot.js — The Assembly Site of the Reconciler

Let's see how the react-dom package assembles the reconciler and HostConfig together.

// https://github.com/facebook/react/blob/main/packages/react-dom/src/client/ReactDOMRoot.js
import {
  createContainer,
  updateContainer,
  flushSync,
} from 'react-reconciler/src/ReactFiberReconciler';

// Inside ReactFiberReconciler, it imports './ReactFiberConfig'
// During Rollup build, this import is replaced with react-dom-bindings' ReactFiberConfigDOM

export function createRoot(container: Element | Document | DocumentFragment): RootType {
  const root = createContainer(
    container,           // Container DOM node
    ConcurrentRoot,      // root type
    null,                // hydration callbacks
    false,               // isStrictMode
    null,                // concurrent updates by default
    identifierPrefix,
    onUncaughtError,
    onCaughtError,
    onRecoverableError,
    null,
  );
  // ...
  return {
    render(children) { updateContainer(children, root, null); },
    unmount() { updateContainer(null, root, null); },
    _internalRoot: root,
  };
}

createRoot seems to just be calling react-reconciler's createContainer. But the key is—the implementation of createContainer (in ReactFiberReconciler.js) internally calls HostConfig functions. For example, when creating the root fiber, it needs to know how to create a container instance—this calls createInstance.

And in react-dom's build artifact, this createInstance comes from ReactFiberConfigDOM.js, which internally calls document.createElement. If building react-native-renderer, the same createContainer calls Native's UIManager.createView.

This is the essence of "one protocol, multi-platform execution"—the same reconciler source code, linked with different HostConfig implementations, yields different renderers.


4. Capability Matrix — Capability Differences Across Platform Renderers

Different renderers implement the HostConfig protocol to varying degrees. Some features are naturally unsupported on certain platforms, others are design trade-offs.

Capability react-dom react-native react-noop Meaning
Basic tree operations All platforms support
supportsMutation Optional Incremental node updates
supportsPersistence Optional Whole tree replacement
supportsHydration Client-side activation after SSR
Resource preloading link preload/prefetch
Singletons HTML/HEAD/BODY special handling

Note that react-dom has supportsPersistence = false. The DOM natively supports mutation—you can modify an element's attributes or insert a child node at any time. So react-dom chooses the mutation path, not the persistence path.

Certain platforms (like some declarative UI frameworks) might only support persistence—they can only submit a whole new tree to replace the old one, not modify individual nodes. Such a platform would set supportsMutation = false, supportsPersistence = true, and the reconciler would automatically switch algorithms.


5. From React's Protocol Design to Our Engineering

Isolating Change with Protocols

React's Reconciler / Renderer separation is essentially a Protocol-Driven Architecture. The Reconciler defines "what operations I need," and the Renderer implements "how these operations are executed on the platform." The two communicate via the HostConfig protocol.

The value of this architecture is: either side can evolve independently. The Reconciler can upgrade its scheduling algorithm (Fiber → Concurrent → whatever comes next), and as long as the HostConfig interface remains unchanged, all renderers don't need to change. Conversely, a renderer can add new platform capabilities (like react-dom adding View Transition API support), and as long as it implements the corresponding functions in HostConfig, the reconciler can use them.

In your own system, when you need to support multiple platforms or multiple backends, consider defining a core protocol:

The HostConfig Idea Doesn't Just Belong to React

HostConfig's core idea—abstracting platform differences with a set of interface functions—is universal. A few examples:

The key is: business code only depends on the interface, not the concrete implementation. The implementation is injected via build configuration.

Protocol is a Contract for Team Collaboration

HostConfig is not just a code-level interface, but also a team-level contract. The React core team maintains the reconciler, an internal Facebook team maintains react-dom, and the community maintains react-native-renderer. The three teams don't need frequent communication—as long as the HostConfig interface remains unchanged, each can develop and release independently.

This "decoupling teams through protocols" model has important implications for large-scale organizational architecture design:

React's Approach Migration Strategy
HostConfig defines the boundary between reconciler and renderer Define API contracts between teams, rather than directly depending on each other's internal implementations
supportsXxx = false flag lets the reconciler adapt Service degradation strategy—when backend capability is unavailable, the frontend automatically switches to a simplified mode
Rollup alias injects implementation at build time Switch different backend implementations via environment variables in CI/CD; the same business code runs in different environments
shim function throws clear errors instead of silently failing Throw clear errors when an interface is not implemented, rather than returning undefined leading to difficult debugging later

6. The Essence of Good Architecture is Defining Boundaries

Look back at that throw new Error in ReactFiberConfig.js. Twenty lines of code, ninety percent comments, one effective line of code. But it defines the cornerstone of React's entire multi-platform architecture.

The essence of good architecture isn't writing incredibly clever algorithms, but defining clear boundaries—what belongs to this layer, what belongs to that layer, and what protocol the layers communicate through. Once boundaries are well-defined, the internal implementation of each layer can be arbitrarily replaced and evolved without affecting elsewhere.

React's reconciler already has tens of thousands of lines of code—scheduling algorithms, priority systems, Fiber tree management, side effect collection... But this code knows nothing about "which platform it's running on." It only recognizes the dozen or so functions defined in HostConfig. It's these dozen or so functions that allow the same reconciler brain to drive browser DOM, iOS/Android Native views, in-memory test objects, and even new platforms not yet invented in the future.

After that poster editor project failed, I spent a lot of time thinking about "how I would design it if I could do it over." The answer is: define a RendererConfig interface from day one. Canvas operations don't directly call the Canvas API, but go through config.createCanvasContext(). Text editing doesn't directly use contentEditable, but goes through config.createTextEditor(). When the boss says "we need a Mini Program version," I only need to implement a new Mini Program RendererConfig, instead of rewriting two hundred components.

React spent ten years telling us one lesson: Platforms change, protocols endure.