Writing Custom Renderers for React

Authors

When the React team announced React 19, they also announced that React Test Renderer (RTR) was deprecated. This created a challenge for me as a maintainer of React Native Testing Library, because that library is built on top of that renderer.

Despite the name, React Native Testing Library does not use the actual React Native renderer. The reason is that RNTL provides a fast, lightweight, JavaScript-only testing environment. Using the actual React Native renderer would require an iOS or Android environment on a device or simulator.  In practice however, RTR has worked well with React 19.x releases so far, but the clock was ticking. I knew that we needed a new approach that would be viable long-term.

There were three options:

  1. Write a new test renderer.
  2. Use Fantom, a new internal test renderer that React team uses to run integration tests in JS + C++. That would be interesting, but unfortunately, even now, it's not supported outside of the RN core codebase.
  3. Run on device/simulator, going toward E2E testing.

Of those three options, building a new test renderer felt like the most natural path forward for RNTL. It kept the main design choices & trade-offs, while allowing users to continue using their existing test bases. Using Fantom or building on top of RN renderer seemed to be effort better suited for creating a separate library.

So I decided to build one. I had always thought of React renderers as something fairly complex. I knew about React DOM, React Native, React Test Renderer, Ink, etc. On the other hand, how hard can it be? So I gave it a try. I also saw this as an opportunity to make a test renderer better suited for RNTL purposes. React Test Renderer has been a fine tool, but it had its own quirks and features. For example:

  1. It exposed a mixed tree of host and composite components. This was problematic for RNTL, since we want to rely only on platform views, i.e. host components, and a large part of RNTL codebase is responsible for hiding that mixed tree structure.
  2. It allowed strings to be rendered directly in any element, as in the DOM, which is not allowed in React Native, where you have to render strings inside Text components. In order to help users catch such errors better in testing, we created a special workaround for it.

With a new test renderer, I could finally make it more aligned with RNTL needs.

Why React needs a renderer

Like most React Native developers, I've used both React and React Native without really thinking about how they interact under the hood, just being happy that they do it so well. However, in order to understand the role of a renderer, we need to dig deeper.

First, let's focus on three tree-like structures React uses to represent UI through its work:

  1. React element tree: each component we write returns JSX, which is a read-only description of the UI we want to build.
  2. Fiber Tree: an internal React data structure representing combined tree of host & composite elements, used also to organize React work during rendering and committing.
  3. Platform views: the actual UI representing the views, a DOM tree or iOS/Android view hierarchy, this is the output that React produces and manages.

React also uses concept of host instances, for simplicity of our mental model let's treat them as the same thing as platform views. I will mention some cases where these two can actually diverge.

This transformation process is handled by three packages:

  1. React: provides the public API for building components, hooks, JSX
  2. React Reconciler: diffs component tree versions to produce a minimal set of host view updates
  3. Renderer: provides the operations used by the Reconciler to manage the actual host instance hierarchy

Typically you do not install the reconciler directly, it's provided by the renderer. All three elements need to be in compatible versions in order to work correctly. Reconciler performs its work by using element tree in order to build Fiber tree which both represents the component structure and helps it organize rendering work. Conceptually, it maintains two Fiber tree versions: currently rendered tree and work-in-progress (next) tree.

Reconciler work consists of two phases:

  • Rendering: during this process it builds the work-in-progress tree, based on received components. In concurrent React it can be paused and resumed multiple times in order to avoid blocking JavaScript thread and give other code a chance to run. Each fiber represents a unit of work, and React may decide to yield after each of such units. It can also discard partially rendered work-in-progress tree, e.g. when higher priority event triggers a new render.
  • Committing: when the work-in-progress tree is ready, reconciler instructs the renderer with a minimal series of operations that make the UI tree reflect the new Fiber tree. At this stage work-in-progress tree becomes the new current tree and it's no longer changed. This is an atomic operation and cannot be interrupted. After committing, React runs effects.

React and Reconciler communicate under the hood using global ReactSharedInternals object (exported as __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_

CANNOT_UPGRADE), which provides various context information, like hooks dispatcher, async dispatcher, active transition, etc.

On the other hand, Reconciler and renderer communicate using HostContext object, which represents the interface provided by the renderer and consumed by the reconciler. It contains a large number of methods and properties that renderer has to implement and most of them are related to creating and maintaining the platform view tree.

To give you an example what are such methods and properties:

const hostConfig = {
  supportsMutation: true,

  // Creation operations
  createInstance(type, props, ...) { ... }
  createTextInstance(text, rootContainer, hostContext) { ... }

  // Tree manipulation
  appendChild(parent, child) { ... }
  removeChild(parent, child) { ... }

  // Mutations
  commitUpdate(instance, type, prevProps, nextProps, fiber) { ... }
  commitTextUpdate(textInstance, prevText, nextText) { ... }

  // Many, many more
};

There are two main variants of renderers:

  1. Mutation: this is the basic renderer type, used by e.g. React DOM, old RN renderer. It maintains a platform view hierarchy using mutation operations (as shown above). In this article we will focus on this type.
  2. Persistence: an alternative approach developed for React Native Fabric renderer, which avoids mutating the host instance trees and instead creates read-only copies whenever a new version of the tree is needed. This allows for safe access to the host instance tree by multiple threads. In order to make this process efficient, large parts of the previous tree version can be re-used. In this article we won't cover this mode.

Let's look at how a simple JSX tree can be translated into host config call sequence:

<View>
  <Text>Hello</Text>
</View>

Reconciler will call following methods during initial render:

  • createInstance("View", props) creates the host instance
  • createInstance("Text", props) creates the text host instance
  • createTextInstance("Hello") creates the text instance
  • appendInitialChild(textHost, text) attaches the text instance to the Text
  • appendInitialChild(view, textHost) attaches the Text to the View
  • appendChildToContainer(root, view) attaches the View to the root container

During updates, reconciler might call other methods to mutate the platform view, e.g.:

  • commitUpdate(view, oldProps, newProps) updates an existing host instance
  • removeChild(parent, child) removes a child from its parent
  • hideInstance(instance) hides instance e.g. for suspense
  • and many, many more

The actual structure of host instance tree depends on the renderer. In case of React DOM renderer, host instances are actual DOM Elements, so the platform views of the Web.

However, in case of React Native it's more complex, because host instance tree is a data structure holding info about native view (native tag), reference to the shadow tree nodes, and various other data. The actual platform view like UIView or android ViewGroup are naturally mutable, but the host instance tree is immutable and hence can be safely read by multiple threads.

Building a test renderer

The test renderer’s host instance tree is relatively simple. It's a tree of objects that will be used to store the information about the platform views (props, parent, children, etc) but it will not be backed by any actual UI. This data will be used to validate assertions made by user tests.

Each tree has to start with a root node called container:

type Container = {
  tag: "CONTAINER";
  children: Array<Instance | TextInstance>;
};

type Instance = {
  tag: "INSTANCE";
  type: string;
  props: Props;
  children: Array<Instance | TextInstance>;
  parent: Container | Instance | null;
  isHidden: boolean;
};

type TextInstance = {
  tag: "TEXT";
  text: string;
  parent: Container | Instance | null;
  isHidden: boolean;
};

This allows for simple implementation of basic operations: creating instances, adding, updating and removing host instance tree nodes:

function appendChild(parentInstance: Container | Instance, child: Instance | TextInstance): void {
  const index = parentInstance.children.indexOf(child);
  if (index !== -1) {
    parentInstance.children.splice(index, 1);
  }

  child.parent = parentInstance;
  parentInstance.children.push(child);
}

function removeChild(parentInstance: Container | Instance, child: Instance | TextInstance): void {
  const index = parentInstance.children.indexOf(child);
  parentInstance.children.splice(index, 1);
  child.parent = null;
}

function commitUpdate(
  instance: Instance,
  type: Type,
  _prevProps: Props,
  nextProps: Props,
  fiber: Fiber,
): void {
  instance.type = type;
  instance.props = nextProps;
  instance.unstable_fiber = fiber;
},

Note that the TestInstances exposed by Test Renderer (this is also true for RTR) are thin wrappers on top of the actual host instances, in order to provide better DX by encapsulating the raw data and exposing various helpers.

The beauty of the reconciler approach is that it separates the generic logic of React rendering algorithm, and requires renderer only to implement a set of methods to transform these generic operations to actual platform logic. This means that React features like concurrent rendering, suspense, transitions, etc. are implemented in the reconciler package.

Rough edges

One of the biggest hurdles to complete the migration of RNTL to Test Renderer was the Fire Event API in RNTL, which allows user to invoke any event on given component. In React Native, host element rendered by Pressable actually lacks onPress prop, instead they have a low level pressability events like onResponderGrant, onResponderRelease.

In order to support press and other arbitrary events, Fire Event API inspects not only host elements, but also their parent composite elements. This has been possible with React Test Renderer which exposed a mixed tree of host and composite elements.

However, Test Renderer manages only host elements tree. In order to solve that dilemma and support the Fire Event API I had to build an escape hatch unstable_fiber to allow access to the composite elements props as well.

There is a tight coupling between React package and reconciler versions, but they use different version schemes which is somewhat confusing. In a similar way renderer is also tightly coupled to reconciler and hence React version.

What building a renderer taught me about React

React is a library for building user interfaces. It works by transforming the declarative description of UI (React Elements) to imperatively managed platform view tree. The transformation algorithm is implemented by React Reconciler in a platform-agnostic way.

The role of the renderer is to translate this generic view actions into actual platform view changes. To write a renderer you have to understand how the three main trees relate: the element tree your components produce, the Fiber tree React maintains internally, and the host instance tree the renderer owns. HostConfig is what connects them, it's the contract you implement so the reconciler can drive your host environment.

Test Renderer is primarily built for React Native Testing Library, but I tried to make it flexible enough so it could be used in different contexts, unrelated to React Native, whenever there is a need for JavaScript-only testing.

Table of contents
Need help with React or React Native projects?

We support teams building scalable apps with React and React Native.

Let’s chat

Insights

Learn more about React

Here's everything we published recently on this topic.