Vite Setup#

Bootstrap a new vite project using vanilla template.

npm create vite@latest jsx-renderer-vite -- --template vanilla

Enter the project and install the dependencies

cd jsx-renderer-vite && npm install

Then, adjust the project files so that the final structure is like this

.
├── src/
│   └── main.jsx
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
└── vite.config.js

The following files are important for this setup process

// vite.config.js

import * as v from "vite";

export default v.defineConfig({
  esbuild: {
    // use h as the JSX Factory function instead of React.createElement
    jsxFactory: "h",
    // use Fragment for JSX fragments (<>)
    jsxFragment: "Fragment",
  },
});
// package.json

{
  "name": "jsx-renderer-vite",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "devDependencies": {
    "vite": "^7.1.7"
  }
}
<!-- index.html -->

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>jsx-renderer-vite</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- change the value of "src" here to main.jsx -->
    <script type="module" src="src/main.jsx"></script>
  </body>
</html>

Creating the h Function#

Now let’s implement the h() function that esbuild will call for each JSX element.

// main.jsx

function h(nodeName, attributes, ...args) {
  let vnode = { nodeName };
  if (attributes) vnode.attributes = attributes;
  if (args.length) vnode.children = [].concat(...args);
  return vnode;
}

This function creates a virtual DOM node object with:

  • nodeName: The HTML tag name (e.g., “div”)
  • attributes: An object of element attributes (optional)
  • children: An array of child nodes (text or other virtual nodes)

The ...args syntax collects all remaining arguments into an array, and [].concat(...args) flattens nested arrays.

Writing the First JSX#

// main.jsx

function h(nodeName, attributes, ...args) {
  let vnode = { nodeName };
  if (attributes) vnode.attributes = attributes;
  if (args.length) vnode.children = [].concat(...args);
  return vnode;
}

const element = <div>Hello World</div>;
console.log(element);

Run npm run dev to start Vite’s development server. Vite will automatically transpile the JSX and you can see the result in the browser console.

Implementing the DOM Renderer#

Now let’s implement the function to convert our virtual DOM nodes into real DOM elements. Add the renderer to src/main.jsx:

// main.jsx

function h(nodeName, attributes, ...args) {
  let vnode = { nodeName };
  if (attributes) vnode.attributes = attributes;
  if (args.length) vnode.children = [].concat(...args);
  return vnode;
}

// Function to render virtual DOM nodes to real DOM elements
function render(vnode) {
  // Handle text nodes
  if (typeof vnode === "string") return document.createTextNode(vnode);

  // Handle functional components
  if (typeof vnode.nodeName === "function") {
    const componentVnode = vnode.nodeName(vnode.attributes || {});
    return render(componentVnode);
  }

  // Create DOM element
  let n = document.createElement(vnode.nodeName);

  // Set attributes
  Object.keys(vnode.attributes || {}).forEach((k) =>
    n.setAttribute(k, vnode.attributes[k]),
  );

  // Render and append children
  (vnode.children || []).forEach((c) => n.appendChild(render(c)));

  return n;
}

const ITEMS = "some random content".split(" ");

// A functional component that returns JSX
function ItemList({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li>{item}</li>
      ))}
    </ul>
  );
}

// Main JSX structure
const vdom = (
  <div id="app">
    <h1>JSX Renderer Demo</h1>
    <p>Look, a simple JSX DOM renderer!</p>
    <ItemList items={ITEMS} />
  </div>
);

document.body.appendChild(render(vdom));

The ItemList function returns JSX, which Vite transpiles to h()~ calls. The {items.map(...)} expression gets embedded directly in the transpiled code. Vite’s fast refresh will update the component instantly when you make changes.