Read OSS

The Host Config and DOM Bindings — Bridging React to the Browser

Advanced

Prerequisites

  • Articles 1-4 (full understanding of the reconciler, fibers, work loop, and hooks)
  • Basic DOM API knowledge (createElement, appendChild, addEventListener)
  • Understanding of event delegation and bubbling

The Host Config and DOM Bindings — Bridging React to the Browser

Until now, we've been exploring React's abstract machinery — fibers, work loops, lanes, hooks. None of that code knows anything about DOM elements, browser events, or CSS. The reconciler is deliberately renderer-agnostic: it speaks in terms of "instances," "text instances," and "containers" — abstract types that could be DOM nodes, native views, or terminal text.

This article explains how that abstraction works. We'll examine the host config contract that every renderer must implement, trace how react-dom-bindings fulfills it for the browser, walk through createRoot's wiring, deep-dive into the synthetic event system, and follow the commit phase as it applies DOM mutations.

The Host Config Contract

As we saw in Article 1, ReactFiberConfig.js is a throw sentinel:

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

The fork system replaces this file with a renderer-specific implementation at build time. For DOM, the fork at ReactFiberConfig.dom.js re-exports from react-dom-bindings and react-client:

export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';

The host config is an implicit interface — there's no single TypeScript/Flow interface definition. Instead, the reconciler imports specific named exports from ./ReactFiberConfig, and each renderer must provide those exports. The key operations include:

classDiagram
    class HostConfig {
        +createInstance(type, props, root, context) Instance
        +createTextInstance(text, root, context) TextInstance
        +appendChild(parent, child) void
        +appendChildToContainer(container, child) void
        +insertBefore(parent, child, before) void
        +removeChild(parent, child) void
        +commitUpdate(instance, type, oldProps, newProps) void
        +commitTextUpdate(textInstance, oldText, newText) void
        +prepareUpdate(instance, type, oldProps, newProps) UpdatePayload
        +shouldSetTextContent(type, props) boolean
        +getCurrentUpdatePriority() EventPriority
        +setCurrentUpdatePriority(priority) void
    }

The reconciler calls these functions at specific points: createInstance during completeWork for new fibers, commitUpdate during the mutation commit phase, appendChild and insertBefore for Placement effects, and removeChild for deletions.

The DOM Host Config Implementation

The actual DOM implementation lives in the massive ReactFiberConfigDOM.js file inside react-dom-bindings. Here's how createInstance works:

export function createInstance(
  type: string,
  props: Props,
  rootContainerInstance: Container,
  hostContext: HostContext,
  internalInstanceHandle: Object,
): Instance {
  // ... validation in DEV
  const ownerDocument = getOwnerDocumentFromRootContainer(rootContainerInstance);
  // Creates the actual DOM element
  const domElement = createElement(type, props, ownerDocument, hostContext);
  // Caches the fiber reference on the DOM node
  precacheFiberNode(internalInstanceHandle, domElement);
  // Caches the props for later diffing
  updateFiberProps(domElement, props);
  return domElement;
}

The function delegates to the standard document.createElement (or createElementNS for SVG), then stores internal references on the DOM node itself. This bidirectional link between fibers and DOM nodes is essential for the event system — when a DOM event fires, React needs to find the corresponding fiber to locate event handlers.

For updates, the prop diffing algorithm runs in two phases. During completeWork, prepareUpdate computes a diff (which props changed). During the commit mutation phase, commitUpdate applies the diff to the DOM element via updateProperties. This split means the render phase does the expensive comparison, while the commit phase does minimal work — important because the commit phase is synchronous and blocks the main thread.

Tip: The react-dom-bindings package exists as a separate package from react-dom specifically to enable the server rendering code paths (Fizz) to import DOM-specific utilities without pulling in the entire client-side reconciler.

createRoot and hydrateRoot — Wiring It All Together

createRoot is the entry point that wires everything together:

sequenceDiagram
    participant User as User Code
    participant CR as createRoot()
    participant FR as createFiberRoot()
    participant Events as listenToAllSupportedEvents()
    participant Root as ReactDOMRoot

    User->>CR: createRoot(document.getElementById('root'))
    CR->>CR: Validate container
    CR->>FR: createFiberRoot(container, ConcurrentRoot, ...)
    Note over FR: Creates FiberRoot + HostRoot fiber
    CR->>Events: listenToAllSupportedEvents(container)
    Note over Events: Attaches delegated event listeners
    CR->>Root: new ReactDOMRoot(root)
    Root-->>User: { render(), unmount() }

The function:

  1. Validates that the container is a real DOM element
  2. Parses options (strict mode, error handlers, transition callbacks)
  3. Calls createContainer from the reconciler, which creates a FiberRoot and its initial HostRoot fiber (as we covered in Article 2)
  4. Marks the container as a React root via an internal property
  5. Sets up the event system by calling listenToAllSupportedEvents on the container
  6. Returns a ReactDOMRoot object with render() and unmount() methods

When you call root.render(<App />), it calls updateContainer from the reconciler, which creates an update on the HostRoot fiber, assigns it a lane, and kicks off the scheduling process (as we traced in Article 3).

hydrateRoot follows the same flow but marks the root for hydration. During the initial render, instead of creating new DOM nodes, the reconciler attempts to reuse the existing server-rendered HTML by matching fibers to existing DOM nodes.

The Event System — Delegation and Synthetic Events

React's event system is one of the most complex pieces of react-dom-bindings. Instead of attaching event listeners to individual DOM nodes, React delegates all events to the root container.

listenToAllSupportedEvents iterates over all known native events and attaches listeners:

export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
  if (!(rootContainerElement: any)[listeningMarker]) {
    (rootContainerElement: any)[listeningMarker] = true;
    allNativeEvents.forEach(domEventName => {
      if (domEventName !== 'selectionchange') {
        if (!nonDelegatedEvents.has(domEventName)) {
          listenToNativeEvent(domEventName, false, rootContainerElement);
        }
        listenToNativeEvent(domEventName, true, rootContainerElement);
      }
    });
  }
}

Each listener is wrapped with the appropriate priority via createEventListenerWrapperWithPriority:

export function createEventListenerWrapperWithPriority(
  targetContainer, domEventName, eventSystemFlags,
): Function {
  const eventPriority = getEventPriority(domEventName);
  let listenerWrapper;
  switch (eventPriority) {
    case DiscreteEventPriority:
      listenerWrapper = dispatchDiscreteEvent;
      break;
    case ContinuousEventPriority:
      listenerWrapper = dispatchContinuousEvent;
      break;
    // ...
  }
}

This connects the DOM event system to the lane model: click events get DiscreteEventPriority (maps to SyncLane), scroll/drag get ContinuousEventPriority (maps to InputContinuousLane), and everything else gets DefaultEventPriority.

flowchart TD
    NE["Native DOM Event<br/>(click on button)"] --> RL["Root Listener<br/>(delegated)"]
    RL --> GT["getEventTarget()<br/>Find DOM node"]
    GT --> GF["getClosestInstanceFromNode()<br/>Find fiber"]
    GF --> DP["dispatchEventForPluginEventSystem<br/>Walk fiber tree for handlers"]
    DP --> SE["Create SyntheticEvent"]
    SE --> HH["Call handler(syntheticEvent)"]
    HH --> SU["State updates batched<br/>via lane system"]

When a native event fires, React:

  1. Finds the target DOM node from the native event
  2. Looks up the corresponding fiber via the cached bidirectional reference
  3. Walks up the fiber tree collecting event handlers (simulating bubbling)
  4. Creates a SyntheticEvent wrapper
  5. Calls handlers in order, with any setState calls automatically batched

Commit Phase DOM Operations

As we saw in Article 3, the commit mutation phase processes fibers with MutationMask flags. Let's see what those flags translate to in DOM operations.

Placement — The fiber is newly mounted or moved. The commit phase calls appendChild or insertBefore on the host config, translating to parentNode.appendChild(domNode) or parentNode.insertBefore(domNode, beforeNode).

Update — Props changed. commitMutationEffectsOnFiber calls commitUpdate, which applies the diff computed during completeWork. This might update DOM attributes, event handlers, or styles.

ChildDeletion — A child was removed. React walks the deleted subtree, calls removeChild on the DOM, unmounts refs, and runs cleanup for effects.

Ref — In the mutation phase, refs on unmounting fibers are detached (set to null). In the layout phase, refs on newly mounted/updated fibers are attached (set to the DOM instance).

flowchart LR
    subgraph "Mutation Phase"
        P["Placement<br/>appendChild / insertBefore"]
        U["Update<br/>commitUpdate (apply prop diff)"]
        D["ChildDeletion<br/>removeChild + cleanup"]
        RD["Ref Detach<br/>ref.current = null"]
    end

    subgraph "Layout Phase"
        RA["Ref Attach<br/>ref.current = domNode"]
        LE["useLayoutEffect<br/>Run callbacks"]
    end

    P --> RA
    U --> LE

The layout phase runs after DOM mutations, so useLayoutEffect callbacks and componentDidMount/componentDidUpdate see the updated DOM. This is also when refs are attached, which is why you can read ref.current inside useLayoutEffect to get the newly mounted DOM node.

Other Renderers — React Native, Test, Custom

The same host config contract that react-dom-bindings implements is also implemented by other renderers:

Renderer Fork File Host Instance Type
React DOM ReactFiberConfig.dom.js DOM Element
React Native ReactFiberConfig.native.js Native view handles
Custom (npm) ReactFiberConfig.custom.js User-defined types

The custom renderer config uses a clever trick. The standalone react-reconciler npm package wraps the entire reconciler in a factory function:

module.exports = function ($$$config) {
  /* reconciler code, with $$$config as the host config */
}

The $$$config parameter looks like a global variable inside the reconciler code, but it's actually the host config argument passed by the custom renderer. This is documented in the custom fork file.

Tip: If you're building a custom renderer (e.g., for Canvas, WebGL, or a terminal), start with the react-reconciler npm package. You only need to implement the host config operations — the entire fiber architecture, work loop, hooks system, and scheduling come for free.

What's Next

We've now completed the client-side picture — from the public API (react), through the reconciler's fiber architecture and work loop, to the DOM renderer's host config and event system. In the final article, we'll explore React's server-side architectures: Fizz for streaming SSR, Flight for React Server Components, and how the use client / use server directives bridge the client-server boundary through bundler integration.