skip to content

React Test Renderer

You will learn the pre-DOM way to render React — into plain JavaScript objects — which is why it survives in React Native and snapshot workflows. Interviewers mostly want to hear why it is now legacy for web work and what replaced it.

on this pageshow

questions

4

The react-test-renderer package renders a React component without any DOM. What does `TestRenderer.create(<Profile />).toJSON()` return, and why would a project want a renderer that produces that instead of DOM nodes?

level: juniorimportance: should knowfreq 32%

answer

  1. reconciler and host layer are separate
  2. host instances here are plain objects
  3. type, props, children — no document
  4. your components flatten away
  5. born for React Native, no DOM there

basics

~20 s

toJSON() returns a plain JavaScript object tree of the host elements React produced — each node has type, props and children, with text as plain strings. No DOM node is created, so the renderer runs anywhere React runs, including React Native.

solid answer

~40 s

`TestRenderer.create()` runs React's normal reconciliation, but its host layer builds plain JavaScript objects instead of DOM nodes. Calling `.toJSON()` on the returned renderer serialises the *host* part of that tree: each node is roughly `{ type, props, children }`, where `type` is the host element name such as `'div'` or `'View'`, and text children are plain strings. Your own components do not appear — only what they rendered down to. The point is host-independence: React's reconciler is separate from the thing that creates host instances, so this renderer works with no `document` at all. That is why React Native adopted it, since there are no DOM nodes on a phone, and why it feels fast in a bare Node process. The same renderer also exposes `.root` for walking the tree, plus `.update()` and `.unmount()`.

code

javascript · 19 lines
javascript
const React = require('react');
const TestRenderer = require('react-test-renderer');

function Profile({ name }) {
  return React.createElement(
    'div',
    { className: 'profile' },
    React.createElement('h1', null, name),
  );
}

const renderer = TestRenderer.create(
  React.createElement(Profile, { name: 'Ada' }),
);

console.log(JSON.stringify(renderer.toJSON(), null, 2));
// { "type": "div", "props": { "className": "profile" },
//   "children": [ { "type": "h1", "props": {}, "children": ["Ada"] } ] }
// Note: Profile itself does not appear - only the host elements it produced.

go deeper

for a junior

Be able to say plainly that this renderer produces a tree of plain JavaScript objects with type, props and children, and that no DOM node is created anywhere.

for a middle

Explain the mechanism: React's reconciler is host-agnostic, and this package supplies a host layer that allocates objects. Note that toJSON shows only host elements while root and toTree keep component boundaries.

for a senior

Be ready to judge what such a tree can and cannot prove about a component, and to say which assertions in an inherited suite are actually load-bearing versus merely describing structure.

for a principal

Own the framing that renderer choice is an environment-fidelity decision with a cost curve — object tree, simulated DOM, real browser — and be able to argue where a given codebase should sit and why.

## React has two halves, and only one of them is the DOM React is not one library. The **reconciler** decides what the UI should look like — it runs your components, diffs element trees and works out what changed. A **host renderer** turns those decisions into something real: `react-dom` creates and mutates DOM nodes, React Native creates native views, and `react-test-renderer` creates ordinary JavaScript objects. All three drive the identical reconciler, so hooks, state, effects and reconciliation behave the same in each. Only the host instances differ. That split is the whole reason a DOM-less renderer can exist. When people say "react-test-renderer renders without a DOM", they mean its host layer never calls `document.createElement`; it allocates a small object per host element instead. ## What create() hands back ```js const TestRenderer = require('react-test-renderer'); const renderer = TestRenderer.create(React.createElement(Profile, { name: 'Ada' })); ``` `renderer` is not the tree — it is a handle onto a mounted render, with a few members: - `renderer.toJSON()` — the serialised host tree (below). - `renderer.toTree()` — a richer tree that also includes your composite components. - `renderer.root` — a test instance you can walk with `findByType`, `findAllByType`, `findByProps`, reading `.props` and `.children`. - `renderer.update(element)` — re-render at the root, the equivalent of a parent passing new props. - `renderer.unmount()` — unmount and run cleanups. ## The shape of toJSON output Each node is a plain object with three fields: `type`, `props` and `children`. `type` is the host element name — `'div'`, `'input'`, `'View'` — never one of your components. `props` are the host props that were passed down. `children` is an array whose entries are either more nodes or plain strings for text, or `null` when there are none. Rendering nothing at all yields `null`. ```js // { type: 'div', // props: { className: 'profile' }, // children: [ { type: 'h1', props: {}, children: ['Ada'] } ] } ``` The important detail is that your components have already been flattened away. A `<Profile>` that renders a `<Card>` that renders a `<div>` shows up as just the `div`. That mirrors what the DOM would end up containing, which is exactly why this output has historically been fed to structural snapshot assertions. ## Why anyone wanted plain objects Three reasons, in rough order of importance. **React Native has no DOM.** On a phone the host instances are native views owned by the platform, and you cannot spin those up in a unit test. A renderer that fabricates plain objects gives React Native a way to run component logic in a plain Node process at all. This is the original motivation and the reason the package survived in the React Native world long after web testing moved on. **No environment to set up.** Rendering into a DOM means running a DOM implementation such as jsdom, or a real browser. The object renderer needs neither, so it starts fast and has fewer moving parts. **The result is inspectable data.** A tree of plain objects can be walked, serialised and diffed with ordinary JavaScript, without any query API. ## What the output is emphatically not It is not the DOM, and nothing DOM-shaped happens around it. There is no layout, no CSS, no focus, no scrolling, and no browser event system. Refs to host elements are `null` unless you supply a node mock. "Clicking" means reaching into the tree and invoking a prop function yourself — no bubbling, no default behaviour, no `disabled` guard, no capture phase. So a green result tells you the component *produced a certain shape*, not that a user could operate it. ## Where it stands today React 19 deprecates `react-test-renderer` and logs a warning when it is used; the recommended paths are `@testing-library/react` for web components and `@testing-library/react-native` for React Native. Understanding the object tree is still worth having: it explains what the older tests in a legacy suite are actually asserting, and it is a clean illustration of how far a renderer can be from the environment your users see.

  • If toJSON() only shows host elements, how would you inspect a component boundary in that tree?
    Use `renderer.root` rather than `toJSON()`. The root test instance keeps composite components, so `renderer.root.findByType(Card)` locates the component node and lets you read its `props`. `renderer.toTree()` similarly returns a tree that includes component nodes. Both are inspecting structure rather than behaviour, though — useful for debugging, weak as an assertion target, because they bind the test to how the component is decomposed internally.
  • Does state, effects and reconciliation behave differently under this renderer than under react-dom?
    No — the reconciler is shared. Hooks, state updates, keys, bailouts and effect ordering are the same code path; only the host layer differs. What changes is everything the host owns: element creation, event dispatch, layout, focus and refs. So component *logic* is exercised faithfully, while component *interaction* is not exercised at all.
  • Why does the returned tree contain no CSS or computed styles?
    Because styling belongs to the host. `react-dom` hands class names and inline styles to a browser that resolves the cascade and computes layout; the object renderer just parks the `className` or `style` prop on a plain object and stops. Nothing resolves them, so any test about appearance, spacing or visibility has to run somewhere that actually has a styling engine.

It is like reviewing a building from the bill of materials rather than from the finished building: the list tells you which parts were ordered and in what arrangement, but nothing about whether the doors open.

saying these in an interview costs you the question

  • Claims toJSON returns an HTML string of the markup
  • Thinks it renders into jsdom under the hood
  • Expects your own components to appear in the JSON tree
  • Believes querySelector or getByRole works on the result
  • Assumes hooks behave differently because there is no DOM

context

open as a page

A React 19 web app's test suite still renders components with the react-test-renderer package, and CI now prints a deprecation warning for it. Why was that renderer deprecated for web testing, and what do you move those tests to?

level: middleimportance: should knowfreq 44%

basics

~20 s

react-test-renderer renders to fake host objects rather than to a DOM, so web tests never exercise the environment users get. React 19 deprecates it and logs a warning; move web tests to @testing-library/react and React Native tests to @testing-library/react-native.

open as a page

A React component suite uses the react-test-renderer package and triggers behaviour by calling props on the returned tree, such as `renderer.root.findByType(SaveButton).props.onClick()`. Every test is green while the Save button is unusable in the browser. Which classes of defect can a renderer with no DOM never catch?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Anything the host owns stays invisible: real event dispatch and bubbling, disabled or covered controls, focus and keyboard operation, form semantics, CSS-driven visibility and layout, and the accessibility tree. Calling a prop bypasses every gate a real user would have to pass.

open as a page

Under the react-test-renderer package, a React component whose effect runs `inputRef.current.focus()` blows up on a null ref, though the same component works in the browser. Why is the ref null there, and how do you get such a component to render under that renderer?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

There is no DOM node to attach, so react-test-renderer sets host refs to null by default. Pass the createNodeMock option to TestRenderer.create to return a stand-in object per host element — but a mocked focus() proves nothing about real focus behaviour.

open as a page