React renderer
We now know what a React element is. Time to flip the coin and look at the other side: the renderer.
By "renderer", I mean the mechanism that takes a React element object[1] and makes something visible in the browser. That is the gap we will focus on here.
How does a React element object become a real DOM node?
This time createElement(...) has already done its part. The interesting work starts one layer later, where the renderer reads the element object and turns it into DOM.
We will use the same method as before: start with the smallest attempt, inspect what is missing, and add only the next justified piece.
To keep the shape of the renderer visible, we will stay inside a small subset: host elements such as 'div' and 'ul', ordinary attributes, primitive children, nested elements, and arrays of children.[a]
Browser APIs #
Before the first example, one small piece of demo setup needs an explanation. In every live snippet below, mountNode is an ordinary DOM element that already exists in the page. It is just the container where we attach the DOM nodes our tiny renderer creates.
So when you see:
mountNode.appendChild(domNode);
read it as:
attach the newly created DOM node into the demo's visible output area
mountNode is the pre-existing DOM parent we use to make the result visible. The React element object remains the separate description we are interpreting.
If we already have a React element object, maybe we can hand it directly to the DOM:
const element = React.createElement('div', null, 'Hello');
const domNode = document.createElement(element);
mountNode.appendChild(domNode);
Waiting to run
Not run yet.
This fails immediately.
That is a useful first constraint. document.createElement(...) works with a tag name such as 'div'[2], so the renderer has to extract the relevant field from the React element object.
So the browser is telling us something precise:
the React element object describes the DOM node, and the renderer creates the node itself
The renderer has to read the React element object and decide which DOM API calls to make.
Element type #
The simplest host element gives us the next clue:
const element = React.createElement('div', null, 'Hello');
The type field is 'div', and 'div' is exactly the string document.createElement(...) needs.
That suggests the next attempt:
const element = React.createElement('div', null, 'Hello');
const domNode = document.createElement(element.type);
mountNode.appendChild(domNode);
Waiting to run
Not run yet.
This works, but only partially. We do get a real DOM node and attach it to the page, but the node is empty.
So now we know one more thing:
element.typeis enough to create the host DOM nodepropssupply the remaining information needed to render the node's contents
So type gives us the node. The rest of the renderer's work lives mostly in props.
Children #
For the smallest example, the child is just a string:
const element = React.createElement('div', null, 'Hello');
That means element.props.children contains the text we want.
The smallest next step is:
const element = React.createElement('div', null, 'Hello');
const domNode = document.createElement(element.type);
domNode.textContent = element.props.children;
mountNode.appendChild(domNode);
Waiting to run
Not run yet.
Now we have something important:
- a React element object
- a real DOM node created from
element.type - text pulled from
element.props.children - a real attachment to the DOM with
appendChild(...)[3]
For this tiny case, we already have a working renderer.
A renderer, at least in this minimal sense, is a function that interprets the React element object and creates platform objects from it.
Children become more interesting when they are not plain text.
The previous version works only because the child is a string. But React children can also be other React elements.
If we try to reuse the same approach:
const element = React.createElement(
'div',
null,
React.createElement(
'span',
{ style: 'display:inline-block;padding:0.2rem 0.45rem;border:1px solid currentColor;border-radius:0.4rem;' },
'Hello'
)
);
const domNode = document.createElement(element.type);
domNode.textContent = element.props.children;
mountNode.appendChild(domNode);
Waiting to run
Not run yet.
That output is wrong in a very informative way. We see [object Object], which means the child is another object description rather than plain text.
So the renderer has to do more than copy text. It has to notice when a child is itself a React element and render that child too.
That forces the next structural idea:
rendering nested React elements requires recursion
Once we also add arrays of children, we get a small but recognizable tree renderer:
function appendReactChild(parentNode, child) {
if (child === null || child === undefined || child === false || child === true) {
return;
}
if (Array.isArray(child)) {
for (const item of child) {
appendReactChild(parentNode, item);
}
return;
}
if (typeof child === 'string' || typeof child === 'number') {
parentNode.appendChild(document.createTextNode(String(child)));
return;
}
parentNode.appendChild(renderReactElement(child));
}
function renderReactElement(element) {
const domNode = document.createElement(element.type);
appendReactChild(domNode, element.props.children);
return domNode;
}
const element = React.createElement(
'ul',
null,
React.createElement(
'li',
null,
'A'
),
React.createElement(
'li',
null,
React.createElement('strong', null, 'B')
),
React.createElement(
'li',
null,
'C'
)
);
mountNode.appendChild(renderReactElement(element));
Waiting to run
Not run yet.
This is the first version that really feels like a renderer.
It reads a React element object, creates a DOM element from type, and if the child is itself a React element, it calls the same rendering logic again.
The important part is the shape of the process:
- inspect the current React element
- create the matching DOM node
- render its children
- attach the result
That is the renderer's job in miniature.
At this point we have the tree itself. The next layers still live inside props.
Plain attributes #
Some props map directly to ordinary DOM attributes such as id, className, title, href, and data-*.
function setDomAttributes(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (
name === 'children' ||
name === 'style' ||
value === null ||
value === undefined ||
typeof value === 'boolean' ||
typeof value === 'function'
) {
continue;
}
const attributeName = name === 'className' ? 'class' : name;
domNode.setAttribute(attributeName, String(value));
}
}
function appendReactChild(parentNode, child) {
if (child === null || child === undefined || child === false || child === true) {
return;
}
if (Array.isArray(child)) {
for (const item of child) {
appendReactChild(parentNode, item);
}
return;
}
if (typeof child === 'string' || typeof child === 'number') {
parentNode.appendChild(document.createTextNode(String(child)));
return;
}
parentNode.appendChild(renderReactElement(child));
}
function renderReactElement(element) {
const domNode = document.createElement(element.type);
setDomAttributes(domNode, element.props);
appendReactChild(domNode, element.props.children);
return domNode;
}
const element = React.createElement(
'a',
{
id: 'docs-link',
className: 'pill',
title: 'Open docs',
href: '#demo',
'data-kind': 'example'
},
'Open docs'
);
mountNode.appendChild(renderReactElement(element));
console.log(mountNode.innerHTML);
Waiting to run
Not run yet.
Here the renderer is still doing the same structural work as before. It is simply reading more fields from props and mapping them to DOM attributes.
Style #
React stores style information in props.style as an object. A DOM renderer has to translate that object into DOM style assignments.
function setDomAttributes(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (
name === 'children' ||
name === 'style' ||
value === null ||
value === undefined ||
typeof value === 'boolean' ||
typeof value === 'function'
) {
continue;
}
const attributeName = name === 'className' ? 'class' : name;
domNode.setAttribute(attributeName, String(value));
}
}
function applyDomStyle(domNode, style = {}) {
for (const [name, value] of Object.entries(style)) {
domNode.style[name] = String(value);
}
}
function appendReactChild(parentNode, child) {
if (child === null || child === undefined || child === false || child === true) {
return;
}
if (typeof child === 'string' || typeof child === 'number') {
parentNode.appendChild(document.createTextNode(String(child)));
return;
}
parentNode.appendChild(renderReactElement(child));
}
function renderReactElement(element) {
const domNode = document.createElement(element.type);
setDomAttributes(domNode, element.props);
applyDomStyle(domNode, element.props.style);
appendReactChild(domNode, element.props.children);
return domNode;
}
const element = React.createElement(
'div',
{
style: {
padding: '0.45rem 0.6rem',
border: '1px solid currentColor',
borderRadius: '0.5rem',
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
},
'Styled by props'
);
mountNode.appendChild(renderReactElement(element));
Waiting to run
Not run yet.
That gives us a useful contrast:
- plain attributes usually map well to
setAttribute(...) styleneeds its own object-to-DOM translation step
Event handlers #
Props can also carry behavior. A small renderer can recognize names such as onClick and turn them into DOM event listeners[5].
function setDomAttributes(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (
name === 'children' ||
name === 'style' ||
value === null ||
value === undefined ||
typeof value === 'boolean' ||
typeof value === 'function'
) {
continue;
}
const attributeName = name === 'className' ? 'class' : name;
domNode.setAttribute(attributeName, String(value));
}
}
function addDomEventListeners(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (!name.startsWith('on') || typeof value !== 'function') {
continue;
}
const eventName = name.slice(2).toLowerCase();
domNode.addEventListener(eventName, value);
}
}
function appendReactChild(parentNode, child) {
if (child === null || child === undefined || child === false || child === true) {
return;
}
if (typeof child === 'string' || typeof child === 'number') {
parentNode.appendChild(document.createTextNode(String(child)));
return;
}
parentNode.appendChild(renderReactElement(child));
}
function renderReactElement(element) {
const domNode = document.createElement(element.type);
setDomAttributes(domNode, element.props);
addDomEventListeners(domNode, element.props);
appendReactChild(domNode, element.props.children);
return domNode;
}
const element = React.createElement(
'button',
{
onClick() {
clickMessageNode.textContent = 'Clicked';
console.log('clicked');
}
},
'Click me'
);
const clickMessageNode = document.createElement('p');
clickMessageNode.textContent = 'Waiting for click';
clickMessageNode.style.marginTop = '0.5rem';
const button = renderReactElement(element);
mountNode.appendChild(button);
mountNode.appendChild(clickMessageNode);
Waiting to run
Not run yet.
The demo also updates visible text after the click, so the mounted output changes alongside the captured log.
This is already enough to show the shape of the mapping: React stores a function in props.onClick, and the renderer attaches that function to the DOM node as a click listener.
Boolean props #
Some props control DOM state more naturally through properties than through string attributes. disabled and checked are good examples.
function setDomAttributes(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (
name === 'children' ||
name === 'style' ||
value === null ||
value === undefined ||
typeof value === 'boolean' ||
typeof value === 'function'
) {
continue;
}
const attributeName = name === 'className' ? 'class' : name;
domNode.setAttribute(attributeName, String(value));
}
}
function setBooleanProps(domNode, props = {}) {
for (const [name, value] of Object.entries(props)) {
if (typeof value !== 'boolean') {
continue;
}
if (name in domNode) {
domNode[name] = value;
continue;
}
if (value) {
domNode.setAttribute(name, '');
}
}
}
function appendReactChild(parentNode, child) {
if (child === null || child === undefined || child === false || child === true) {
return;
}
if (Array.isArray(child)) {
for (const item of child) {
appendReactChild(parentNode, item);
}
return;
}
if (typeof child === 'string' || typeof child === 'number') {
parentNode.appendChild(document.createTextNode(String(child)));
return;
}
parentNode.appendChild(renderReactElement(child));
}
function renderReactElement(element) {
const domNode = document.createElement(element.type);
setDomAttributes(domNode, element.props);
setBooleanProps(domNode, element.props);
appendReactChild(domNode, element.props.children);
return domNode;
}
const element = React.createElement(
'div',
null,
React.createElement('button', { disabled: true }, 'Disabled'),
React.createElement('input', { type: 'checkbox', checked: true })
);
mountNode.appendChild(renderReactElement(element));
const button = mountNode.querySelector('button');
const checkbox = mountNode.querySelector('input');
console.log('disabled:', button.disabled, 'checked:', checkbox.checked);
Waiting to run
Not run yet.
That gives us the last useful piece for this article. Some props naturally become DOM properties rather than text attributes, and the renderer has to know the difference.
Filling the hole #
Because we started with a real React element object, we could separate two jobs very clearly:
React.createElement(...)builds the description- the renderer interprets that description and creates real DOM nodes
That is why the element object and the DOM node must stay conceptually separate. One is a description. The other is the thing the browser actually owns.
Our tiny renderer also shows why React DOM is more than a thin wrapper around document.createElement(...). Even this small subset already needed rules for:
- host tags
- text children
- nested elements
- arrays of children
- plain attributes
- style objects
- event handlers
- boolean props
And real React DOM still has far more work to do beyond that.
Summary #
In this article, root.render(...) stayed in the position of the hole, and we filled a small part of that job ourselves.
That process gave us the answer:
React.createElement(...)returns a React element object- the renderer turns that object into DOM nodes the browser can display
element.typetells us which DOM element to createelement.props.childrengives the renderer the content and tree structure- ordinary props can become DOM attributes, styles, event listeners, or boolean DOM state
- nested elements and arrays force the renderer to walk the element tree recursively
So the right mental model is:
React.createElement(...)gives us the description, and the renderer turns that description into real DOM.
Now that we can manually turn an element tree into a DOM tree, the next question is harder and more interesting:
once something is already on the page, how should a renderer update it when the next element tree arrives?
Notes
- The live demos in this article use the React 19.2.5 development bundle and browser DOM APIs. The tiny renderer we build here only handles a small subset of React elements: host tags, children, plain attributes, style objects, DOM event handlers, and common boolean props. · Back