NashTech Blog

A mental model of how Virtual DOM and Fiber-style rendering works

Table of Contents

While browsing the internet to gather materials, I realized a common pattern in most Virtual DOM articles today:
they focus heavily on pros & cons (sometimes even spreading incorrect information), or they stay at a very high-level conceptual explanation without really helping frontend developers—or developers in general—understand what’s actually going on under the hood.

You’ve probably heard statements like these before:

  • Virtual DOM is the best because React uses it, and React is the best.
  • Virtual DOM is simply a way to represent the DOM as JavaScript objects, and whenever the object changes, it maps the changes back to the real DOM.
  • Virtual DOM is fast and has great performance.
  • Bla bla… mostly praise.

On top of that, many low-quality (but expensive) courses—especially those targeting career switchers or even CS students—tend to teach just enough to “get a job”, while skipping or hand-waving important core concepts.
Recently, I’ve even seen courses with the mindset:

Facebook → React → React is the best

Which leads to misconceptions among learners.

In reality, there are plenty of foreign blog posts that criticize the Virtual DOM approach:

Despite all that, since Virtual DOM is still hot (even though it’s not as fast as many people claim), I decided to write this article anyway… just to grab a bit of fame 😆


⚠️ Warning ⚠️

This article focuses on basic concepts, not a 100% accurate or complete implementation compared to real-world systems like react-reconciler.

If you want to understand how React actually works with Virtual DOM in detail, please refer directly to the official repo:

👉 https://github.com/facebook/react/tree/main/packages/react-reconciler

This implementation demonstrates tree construction and scheduling concepts of Virtual DOM. It does not include reconciliation, diffing, or update logic like React does.

Enough talking. Let’s get into the real thing.

In this article, I won’t repeat surface-level explanations of Virtual DOM—you can find those everywhere.
What we’re going to do today is:

“Create your own simple Virtual DOM”
Talk is cheap. Show me the code.

Step 1: Create createVNode

Open VSCode (why VSCode? Because I like it 😁—use whatever you want).

Create a file named vnode.js.

We’ll start with a function createVNode that takes:

  • tag: HTML tag name (div, span, p, …)
  • props: attributes like id, class, style, etc.
  • dom: the actual DOM node corresponding to this VNode
  • parent: parent VNode
  • child: first child VNode
  • sibling: next sibling VNode
function createVNode(tag, props) {
  return {
    tag,
    props,
    dom: null,
    parent: null,
    child: null,
    sibling: null
  };
}

Step 2: Create createDom

This function receives a VNode and renders it into the real DOM.

Logic:

  • If it’s a text element → create a text node
  • Otherwise → create an element node
  • Map props to DOM attributes
  • Handle special props like style and events
const EVENTS = ["onClick", "onChange"];

function createDom(vnode) {
  const dom =
    vnode.tag === "TEXT_ELEMENT"
      ? document.createTextNode(vnode.props.textContent)
      : document.createElement(vnode.tag);

  if (vnode.tag !== "TEXT_ELEMENT") {
    const isProperty = (key) => key !== "children";

    Object.keys(vnode.props)
      .filter(isProperty)
      .forEach((name) => {
        if (name === "style") {
          const style = vnode.props.style;
          Object.keys(style).forEach((styleKey) => {
            dom.style[styleKey] = style[styleKey];
          });
        } else {
          dom[EVENTS.includes(name) ? name.toLowerCase() : name] =
            vnode.props[name];
        }
      });
  }

  return dom;
}

Step 3: Render Function (Render Phase)

We now create a render function, similar to ReactDOM.createRoot.

Global variables

  • nextUnitOfWork: tells us which VNode should be processed next
  • wipRoot: Work-In-Progress Root, where the new UI tree is built

These concepts are inspired by React Fiber, enabling incremental rendering and avoiding main-thread blocking (unlike old stack-based rendering).

let nextUnitOfWork = null;
let wipRoot = null;

function render(element, container) {
  const rootVNode = createVNode("ROOT", { children: [element] });

  nextUnitOfWork = rootVNode;
  wipRoot = rootVNode;
  wipRoot.dom = container;

  while (nextUnitOfWork) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (wipRoot) {
    commitRoot(wipRoot);
  }
}

Step 4: Unit of Work & Rendering Children

performUnitOfWork

  • Create DOM if it doesn’t exist
  • Render children
  • Decide which VNode to process next (child → sibling → parent)
function performUnitOfWork(vnode) {
  if (!vnode.dom) {
    vnode.dom = createDom(vnode);
  }

  if (vnode.props.children) {
    renderChildren(vnode, vnode.props.children);
  }

  if (vnode.child) {
    return vnode.child;
  }

  let nextVNode = vnode;
  while (nextVNode) {
    if (nextVNode.sibling) {
      return nextVNode.sibling;
    }
    nextVNode = nextVNode.parent;
  }

  return null;
}

renderChildren

This function mutates the VNode tree by linking child and sibling.

function renderChildren(wipVNode, elements) {
  let index = 0;
  let prevSibling = null;

  while (index < elements.length) {
    const element = elements[index];
    let newVNode = null;

    if (typeof element !== "string") {
      newVNode = createVNode(element.tag, element.props);
    } else {
      newVNode = createVNode("TEXT_ELEMENT", { textContent: element });
    }

    newVNode.parent = wipVNode;

    if (index === 0) {
      wipVNode.child = newVNode;
    } else {
      prevSibling.sibling = newVNode;
    }

    prevSibling = newVNode;
    index++;
  }
}

Step 5: Commit Phase

Now we apply everything to the real DOM.

function commitRoot(rootVNode) {
  commitWork(rootVNode.child);
  wipRoot = null;
}

function commitWork(vnode) {
  if (!vnode) return;

  const domParent = vnode.parent.dom;
  domParent.appendChild(vnode.dom);

  commitWork(vnode.child);
  commitWork(vnode.sibling);
}

Final Thoughts

As you can see, keeping a copy of the DOM as objects and checking differences does consume memory 😂.
And React’s performance in 2023 is not as magical as many people claim.

Hopefully, React will evolve into something even more interesting in the future—so we can learn new things again 😃.

Demo with Vite

vite.config.js

import { defineConfig } from "vite";

export default defineConfig({
  plugins: [],
});

package.json

{
  "name": "my-vdom",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  },
  "author": "quangnv"
}

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite</title>
  </head>
  <body>
    <div id="root"></div>
    /src/main.js
  </body>
</html>

src/App.js

(JS object instead of JSX)

function App() {
  return {
    tag: "div",
    props: {
      children: [
        {
          tag: "h1",
          props: {
            children: ["Hello, world!"],
            style: { fontFamily: "sans-serif" }
          }
        },
        {
          tag: "p",
          props: {
            children: ["This is a paragraph."],
            style: { backgroundColor: "red", fontSize: "30px" }
          }
        },
        {
          tag: "button",
          props: {
            children: ["Click me"],
            style: {
              backgroundColor: "green",
              fontSize: "30px",
              cursor: "pointer"
            },
            onClick: () => console.log("click")
          }
        }
      ]
    }
  };
}

export default App;

src/main.js

import { render } from "./vnode/vnode";
import App from "./App";

const rootElement = document.getElementById("root");
render(App(), rootElement);

That’s it 🎉
The Virtual DOM we built here is obviously very primitive—no updates, no state, no effects—but that’s the point.

If everything were implemented here, Facebook wouldn’t need a massive react repository 😄

👉 GitHub: https://github.com/quangnv13/mini-virtual-dom

Picture of quang.nguyenvan@nashtechglobal.com

quang.nguyenvan@nashtechglobal.com

Leave a Comment

Your email address will not be published. Required fields are marked *

Suggested Article

Scroll to Top