Writing Custom Renderers for React

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.

Insights

Learn more about React

cover

Dec 18Live Stream

Building iOS Live Activities With React: Inside Voltra

Learn how to build iOS Live Activities and Dynamic Island experiences using pure JSX, no Swift or native code required.

cover

Nov 27Podcast

From React Native Web to React Strict DOM

Nicolas Gallagher (Meta) explains how React Strict DOM evolved from React Native Web, providing a shared syntax that compiles to native or web with zero overhead.

cover

Nov 21Podcast

React Foundation: New Governance Model for React and React Native

Seth Webster, Executive Director of the React Foundation, details its new governance model, Meta's role, and how partners like Callstack and Microsoft will ensure stability and scale.

cover

Apr 9Podcast

Exploring TanStack Ecosystem: From React Query to TanStack Start

Tanner Linsley breaks down TanStack’s evolution-from React Query to Start, and why client-first React apps still matter in today’s full-stack landscape.

Event Cover

Feb 20Live Stream

Create React App is Out: Exploring Alternatives & Community Proposals

CRA is now deprecated, so we explore other options for starting web React projects, going through Vite, TanStack Router, Parcel, and Rsbuild to check the developer experience.

Event Cover

Feb 13Live Stream

State of React 2024 Breakdown

Let's take a deep dive into the long-awaited State of React survey from 2024; brace yourselves for a two-hour ride.

cover

Sep 9Article

The Complete Developer Guide to React 19, Part 3: Upgraded Support

Learn how React 19's new features enhance SEO, page performance, and custom elements support for better, faster web applications.

cover

Sep 3Article

The Complete Developer Guide to React 19, Part 2: New Enhancements

Discover React 19's key updates, including improved `ref` usage, error reporting, and context consumption in part 2 of this practical developer guide.

cover

Sep 2Article

The Complete Developer Guide to React 19, Part 1: Async Handling

Explore the key features of React 19 beta, focusing on new hooks that enhance async handling, state management, and form submissions.

cover

May 2Article

Understanding React Server Components

React Server Components (RSC) are still work in progress, but now it's the best time to explore these core concepts of future React. Find out why and how!

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.