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.