Vite Setup
Bootstrap a new vite project using vanilla template.
npm create vite@latest jsx-renderer-vite -- --template vanillaEnter the project and install the dependencies
cd jsx-renderer-vite && npm installThen, 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.jsThe 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.