DEV Community

Cover image for React Under the Hood: Building a Tiny Virtual DOM Renderer
Mikhail Angelov
Mikhail Angelov

Posted on

React Under the Hood: Building a Tiny Virtual DOM Renderer

React looks magical until you remove the parts that make it production-ready.

JSX is just function calls. Components are just functions. Virtual DOM nodes are just plain JavaScript objects. Rendering is "just" comparing two trees and patching the DOM.

Of course, real React is much more than that: hooks, scheduling, Fiber, context, refs, synthetic events, hydration, concurrent rendering, and many other things. This article is not about cloning React. The goal is much smaller: to build a tiny React-like renderer from scratch and understand what happens under the hood.

A while ago, I started wondering: why are there so many UI frameworks for the web?

I've been in IT for a long time, and I don't remember UI libraries on other platforms appearing and disappearing as quickly as they do on the web. Desktop libraries like MFC, Qt, and WPF were huge systems that evolved over many years and had relatively few alternatives. On the web, frameworks appear constantly, and the leaders keep changing.

Why?

I think one reason is that the barrier to writing a UI library has dropped dramatically.

Building a library that many people will actually use still takes significant time and expertise. But writing a prototype that is usable for simple demos, especially if it has a decent API, can take surprisingly little time.

If you're curious how, read on.

Why This Article?

There used to be a series on Habr called "Write X in 30 lines of JavaScript." I wondered: can you write React in 30 lines?

I couldn't quite make it in 30, but the final result is close enough.

The goal here is purely educational. This article may help you understand how virtual DOM-based UI frameworks work at a deeper level. I want to show that the basic idea behind another React-like UI renderer is surprisingly accessible.

Before we start, let me clarify what I mean by "UI framework." Opinions vary. Some people consider Angular and Ember to be frameworks, while React is "just a library" for the view layer.

Let's use a simple definition:

A UI framework is a library that helps create, update, and delete pages or individual page elements.

In this sense, many wrappers over the DOM API can qualify. The differences come down to the abstraction they provide for DOM manipulation and how efficient those manipulations are.

By that definition, React is definitely a UI framework.

So let's build a tiny React-like renderer.

Virtual DOM: The Core Idea

React popularized the concept of a virtual DOM. In simplified terms:

Nodes of the real DOM are built in correspondence with nodes of a pre-built virtual DOM tree.

Direct manipulation of the real DOM is discouraged. When changes are needed:

  1. You update the virtual DOM.
  2. The new virtual DOM is compared with the old one.
  3. The differences are collected.
  4. Only the necessary changes are applied to the real DOM.

This can reduce unnecessary DOM operations and makes updates easier to reason about.

The main benefit is not magic performance. The useful idea is the rendering model itself: describe the UI as data, compare two trees, and apply the required DOM changes.

Since the virtual DOM tree is just a regular JavaScript object, it is easy to manipulate: you can create nodes, change them, compare trees, and generate parts of the structure automatically from a declarative syntax such as JSX.

Starting with JSX

Here's what JSX code looks like:

const Component = () => (
  <div className="main">
    <input />
    <button onClick={() => console.log("yo")}>Submit</button>
  </div>
)

export default Component
Enter fullscreen mode Exit fullscreen mode

We want calling Component() to produce a virtual DOM like this:

const vdom = {
  type: "div",
  props: { className: "main" },
  children: [
    { type: "input", props: {}, children: [] },
    {
      type: "button",
      props: { onClick: () => console.log("yo") },
      children: ["Submit"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Of course, we don't want to write this transformation manually. We can use the jsx-transform library, which converts JSX like this:

jsx.fromString("<h1>Hello World</h1>", {
  factory: "h"
})
// => 'h("h1", null, ["Hello World"])'
Enter fullscreen mode Exit fullscreen mode

All we need is to implement the virtual DOM node constructor h: a function that recursively creates virtual DOM nodes.

In React, the equivalent idea is React.createElement.

Here's a minimal implementation:

export function h(type, props, ...stack) {
  const children = (stack || []).reduce(addChild, [])
  props = props || {}

  return typeof type === "string"
    ? { type, props, children }
    : type(props, children)
}

function addChild(acc, node) {
  if (Array.isArray(node)) {
    acc = node.reduce(addChild, acc)
  } else if (node == null || node === true || node === false) {
    // skip null/true/false — they render nothing
  } else {
    acc.push(typeof node === "number" ? String(node) : node)
  }

  return acc
}
Enter fullscreen mode Exit fullscreen mode

The recursion makes the function a little dense, but the idea is simple:

  • flatten nested children;
  • ignore null, true, and false;
  • convert numbers to strings;
  • return either a DOM-like virtual node or a component result.

With this function:

h("h1", null, ["Hello World"])
// => { type: "h1", props: {}, children: ["Hello World"] }
Enter fullscreen mode Exit fullscreen mode

The same works for nested nodes of any depth.

Now our Component function returns a virtual DOM node.

On to the "hard" part.

The Patch Function: Diffing Virtual DOM

We need a function patch that takes:

  • a root DOM element where the app is mounted;
  • the old virtual DOM tree;
  • the new virtual DOM tree.

It then updates the real DOM to match the new virtual DOM.

I based this on code from picodom:

export function patch(parent, oldNode, newNode) {
  return patchElement(parent, parent.children[0], oldNode, newNode)
}

function patchElement(parent, element, oldNode, node, isSVG, nextSibling) {
  if (oldNode == null) {
    // No old node — create a new element.
    element = parent.insertBefore(createElement(node, isSVG), element)
  } else if (node.type !== oldNode.type) {
    // Node type changed — replace it entirely.
    const oldElement = element
    element = parent.insertBefore(createElement(node, isSVG), oldElement)
    removeElement(parent, oldElement, oldNode)
  } else {
    // Same type — update properties.
    updateElement(element, oldNode.props, node.props)

    isSVG = isSVG || node.type === "svg"

    // Collect current child nodes.
    const childNodes = []
    ;(element.childNodes || []).forEach(el => childNodes.push(el))

    let oldNodeIndex = 0

    // Process children.
    if (node.children && node.children.length > 0) {
      for (let i = 0; i < node.children.length; i++) {
        if (
          oldNode.children &&
          oldNodeIndex <= oldNode.children.length &&
          (
            (
              node.children[i].type &&
              node.children[i].type === oldNode.children[oldNodeIndex].type
            ) ||
            (
              !node.children[i].type &&
              node.children[i] === oldNode.children[oldNodeIndex]
            )
          )
        ) {
          // Child exists in the old tree — patch it.
          patchElement(
            element,
            childNodes[oldNodeIndex],
            oldNode.children[oldNodeIndex],
            node.children[i],
            isSVG
          )

          oldNodeIndex++
        } else {
          // New child — insert it.
          const newChild = element.insertBefore(
            createElement(node.children[i], isSVG),
            childNodes[oldNodeIndex]
          )

          patchElement(element, newChild, {}, node.children[i], isSVG)
        }
      }
    }

    // Remove extra old children.
    for (let i = oldNodeIndex; i < childNodes.length; i++) {
      removeElement(
        element,
        childNodes[i],
        oldNode.children ? oldNode.children[i] || {} : {}
      )
    }
  }

  return element
}
Enter fullscreen mode Exit fullscreen mode

This is a naive implementation. It is terribly unoptimized and does not use element identifiers such as key or id for correct list reconciliation.

But for primitive cases, it works fine.

I won't list the helper functions createElement, updateElement, and removeElement here. They are straightforward, and you can find them in the full source.

One Important Caveat

When updating value on <input> elements, the comparison should be made against the real DOM element's current value property, not only against the old vnode value.

Otherwise, a re-render may overwrite what the user is actively typing and break the cursor position or text selection.

This is one of those small details that makes UI rendering much less trivial than it first appears.

Putting It All Together

Now let's assemble the pieces.

The entire renderer API fits in 5 lines:

export function app(selector, view, initProps) {
  // 1. Mount point.
  const rootElement = document.querySelector(selector || "body")

  // 2. Build initial virtual DOM.
  let node = view(initProps)

  // 3. Mount. The old node is null.
  patch(rootElement, null, node)

  // 4. Return a re-render function.
  return props => patch(rootElement, node, (node = view(props)))
}
Enter fullscreen mode Exit fullscreen mode

That is the whole rendering loop.

A "Hello World" example on top of this renderer looks like this:

import { h, app } from "../src/index"

function view(state) {
  return (
    <div>
      <h2>{`Hello ${state}`}</h2>
      <input value={state} oninput={e => render(e.target.value)} />
    </div>
  )
}

const render = app("body", view, "world")
Enter fullscreen mode Exit fullscreen mode

This tiny library supports component composition and runtime adding/removing of components, so it is enough to demonstrate the core rendering idea.

It is not a complete framework in the practical sense. It is a tiny React-like renderer that demonstrates the core pipeline:

JSX → function calls → virtual nodes → diff → DOM patching
Enter fullscreen mode Exit fullscreen mode

You can see a more complex ToDo example.

What's Missing

Of course, this library lacks a lot:

  • Lifecycle events — they would not be hard to add, since we control node creation, update, and removal ourselves.
  • Per-component state updates — something like this.setState would require storing DOM references or instance state per virtual node, which complicates the logic.
  • Performance — the patchElement code is terribly unoptimized and will not scale to large element trees.
  • Key-based reconciliation — there is no proper tracking of elements by identifier.
  • Hooks, refs, context, scheduling, hydration, and Fiber — all the things that make real React a production-grade library.

Conclusion

This library was built for educational purposes only.

Do not use it in production.

But it demonstrates that the core ideas behind React and similar virtual DOM frameworks are surprisingly accessible.

JSX transforms to function calls. Function calls build a tree of plain objects. A diffing function walks that tree and applies the required updates to the real DOM.

The rest — state management, lifecycle, scheduling, Fiber architecture, ecosystem, tooling, and years of edge cases — is engineering on top of this foundation.

P.S. This article was inspired by the excellent Hyperapp library. Some code was adapted from it.

Happy coding!

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How does the tiny virtual DOM renderer handle edge cases like nested components or server-side rendering? I'd love to swap ideas on optimizing render performance.