all posts

Step 1: 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>

Step 2: 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:

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

Step 3: 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.

Step 4: 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.

Advanced JSX Features

Our renderer can be extended to support modern JSX features. Below are working examples for each feature.

JSX Fragments

To support JSX fragments, define a Fragment component and update the render function to handle it:

// 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 Fragment = ({ children }) => children;

function render(vnode) {
  if (typeof vnode === "string") return document.createTextNode(vnode);

  if (vnode.nodeName === Fragment) {
    const fragment = document.createDocumentFragment();
    (vnode.children || []).forEach((c) => fragment.appendChild(render(c)));
    return fragment;
  }

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

  let n = document.createElement(vnode.nodeName);
  Object.keys(vnode.attributes || {}).forEach((k) =>
    n.setAttribute(k, vnode.attributes[k]),
  );
  (vnode.children || []).forEach((c) => n.appendChild(render(c)));
  return n;
}

const component = (
  <>
    <h1>Title</h1>
    <p>Content</p>
  </>
);

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

Conditional Rendering

Conditional rendering uses JavaScript expressions in JSX. This example includes a toggle button to demonstrate dynamic conditional rendering:

// 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 render(vnode) {
  if (typeof vnode === "string") return document.createTextNode(vnode);
  if (typeof vnode.nodeName === "function") {
    const componentVnode = vnode.nodeName(vnode.attributes || {});
    return render(componentVnode);
  }
  let n = document.createElement(vnode.nodeName);
  Object.keys(vnode.attributes || {}).forEach((k) => {
    if (k.startsWith("on") && typeof vnode.attributes[k] === "function") {
      n.addEventListener(k.toLowerCase().slice(2), vnode.attributes[k]);
    } else {
      n.setAttribute(k, vnode.attributes[k]);
    }
  });
  (vnode.children || []).forEach((c) => n.appendChild(render(c)));
  return n;
}

// State for conditional rendering
let show = true;

// Toggle function
function toggleShow() {
  show = !show;
  // Re-render the app
  document.body.innerHTML = "";
  document.body.appendChild(render(<App />));
}

const ConditionalComponent = ({ show }) => (
  <div>
    {show && <p>This shows conditionally</p>}
    {!show && <p>This shows when not shown</p>}
  </div>
);

const App = () => (
  <div id="app">
    <h1>Conditional Rendering Demo</h1>
    <ConditionalComponent show={show} />
    <button onClick={toggleShow}>Toggle Content</button>
  </div>
);

document.body.appendChild(render(<App />));

Event Handling

To handle events, update the render function to attach event listeners for attributes starting with “on”. This example demonstrates a clickable counter:

// 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 render(vnode) {
  if (typeof vnode === "string" || typeof vnode === "number")
    return document.createTextNode(vnode);
  if (typeof vnode.nodeName === "function") {
    const componentVnode = vnode.nodeName(vnode.attributes || {});
    return render(componentVnode);
  }
  let n = document.createElement(vnode.nodeName);
  Object.keys(vnode.attributes || {}).forEach((k) => {
    if (k.startsWith("on") && typeof vnode.attributes[k] === "function") {
      n.addEventListener(k.toLowerCase().slice(2), vnode.attributes[k]);
    } else {
      n.setAttribute(k, vnode.attributes[k]);
    }
  });
  (vnode.children || []).forEach((c) => n.appendChild(render(c)));
  return n;
}

// Counter state
let counter = 0;

function handleClick() {
  counter++;
  // Re-render the app
  document.body.innerHTML = "";
  document.body.appendChild(render(<InteractiveComponent />));
}

const InteractiveComponent = () => (
  <div>
    <p>Counter: {counter}</p>
    <button onClick={handleClick}>Click me</button>
  </div>
);

document.body.appendChild(render(<InteractiveComponent />));