Since React's release in 2013, developers have seemingly fell in-love with JSX and all the cool things it brings with it. I am not much of a fan of React itself, I prefer Vue, but non-the-less, I do love some JSX. It's simple and integrates seamlessly with TypeScript. I always wondered what would go into making my own JSX system and thankfully with Vite, it's not that complicated at all. Today I want to go into how you could make your own custom JSX system using it.

Project Setup

First things first, we need to setup a Vite project. Throughout this article I will be using Yarn instead of npm, but you can freely use whichever package manager you enjoy instead.

Also, you will be able to find the code from this article here:
MichealPearce/custom-jsx-example (github.com)

To get started, we will want to run the create vite command:

yarn create vite custom-jsx-example

Vite will prompt you to pick what framework you will want to use and we will select Vanilla and TypeScript. After Vite has finished scaffolding the project, we will want to open the custom-jsx-example folder in our preferred code editor. I use VSCode, but you can use whichever you want.

Once we have it open, we will want to run the install and dev commands just to ensure it's working correctly.

yarn && yarn dev 

The app should be available on http://localhost:5173/

After confirming everything is in-order, we will want to create a vite.config.ts file as the vanilla project does not come with one. In the config we will define an alias for the src directory and setup esbuild to use our custom jsx. It should look something like this:

import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "@": new URL("./src", import.meta.url).pathname,
    },
  },

  esbuild: {
    jsx: "transform",
    jsxDev: false,
    jsxImportSource: "@",
    jsxInject: `import { jsx } from '@/jsx-runtime'`,
    jsxFactory: "jsx.component",
  },
});

Here we have told esbuild that we want it to transform any JSX code it finds in our source. When building the code, it will inject the jsxInject to the top of the file and use jsx.component when transforming regular components.

Next thing we will do it update the tsconfig.json to work with our custom jsx. There are only two properties we need to add to it for this to work, which are: jsx and jsxImportSource. We will also be adding options for typescript to understand the alias we've added. Here's the updated version:

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    "jsx": "react-jsx",
    "jsxImportSource": "@",

    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"]
}

After that, we will take care of cleaning up some code things with the project. This includes:

  • Clearing out everything in the src folder besides main.ts and moving vite-env.d.ts to types/vite-end.d.ts
  • Renaming main.ts to main.tsx
  • Updating the index.html to point to main.tsx

Now that should be everything we need to do config-wise for our project to start using our custom JSX. Next thing we will do will be going into how to actually implement it.

Implementation

First thing we need to do is create this jsx-runtime file inside of src. The basic file will look something like this:

declare global {
  module JSX {
    type IntrinsicElements = Record<
      keyof HTMLElementTagNameMap,
      Record<string, any>
    >;
  }
}

export const jsx = {
  component() {},
};

The IntrinsicElements type allows us to create native elements in JSX without typescript complaining. Typescript uses this type to determine which component names are globally available to use. We set each one to any as we don't want to have to define what each element can have in-terms of props/attributes. Ideally, they can use whatever attribute you want.

Now you'll notice that jsx.component is a function, and you might be asking why? This will make more since once you see the outputted javascript when it hits the browser. So, we will continue for now to setup some basic code inside of our main.tsx.

Just to see the output, we'll write this:

function Example() {
  return <div>Hello, World!</div>;
}

console.log(<Example />);

Once we save that, we can start up the app and go to the page. It will be blank, but if we open the devtools and go to the Network tab, we can see the code sent to the browser for main.tsx. It should look something like this:

import {jsx} from "/src/jsx-runtime.ts?t=1699377759433";
function Example() {
    return /* @__PURE__ */
    jsx.component("div", null, "Hello, World!");
}
console.log(/* @__PURE__ */
jsx.component(Example, null));

As you can see, esbuild is injecting the jsx like we told it to, and wrapping any jsx it finds in a call to jsx.component. So, all JSX is, is just a different way to call functions, how interesting!

Knowing this, we will go back and update the jsx.component function to actually do something.

export type Component = (props: Record<string, any>) => any;

export const jsx = {
  component(
    component: string | Component,
    props: Record<string, any> | null,
    ...children: any[]
  ) {
    if (!props) props = {};
    props.children = children.flat(Infinity);

    if (typeof component === "function") return component(props);

    const element = document.createElement(component);
    for (const [key, value] of Object.entries(props)) {
      if (key === "children") continue;
      else if (key === "className") element.setAttribute("class", value);
      else element.setAttribute(key, value);
    }

    element.append(...props.children);

    return element;
  },
};

With this, we can actually render HTML now. Going into how this works, we'll look at the component function's signature:

component(
  component: string | Component,
  props: Record<string, any> | null,
  ...children: any[]
)

After looking at the resulting javascript in the previous section, this should make some sense. As we saw, the first thing passed to jsx.component was either the component function or a string, the second was null but that was because we did not have any props added to the components when calling them. And lastly, everything that comes after the props is considered the children.

Diving into the function's code, it goes like this:

  • If props is falsy (null in our case), assign it to an empty object.
  • Completely flatten children as to prevent nested arrays and assign that to props.children
  • If component is a function, simply return the result of calling said function with passing the props to it.
  • Otherwise continue on to create a native HTML element using the DOM API. Setting props to attributes and appending all the children.

It's pretty straightforward. And after updating, we can go back to the browser to inspect the console to see that our main.tsx has actually printed a <div> element with the children Hello, World!. We can take this further by updating our main.tsx with:

function Example() {
  return (
    <div className="test">
      Hello, World!
      <br />
      <button onclick="alert('Hello, There!')">Click Me!</button>
    </div>
  );
}

const app = document.getElementById("app");
app?.append(<Example />);

Going back to the page will reveal that our Example component did indeed get added to the page. The button even works correctly by triggering an alert. Very cool!

Conclusion

While getting to this point with our custom JSX system was relatively simple, it is very basic and lacks a lot of features that modern frameworks have. Getting a custom JSX solution up to par would be a monumental task. Especially for functional components as retaining state on rerenders is no simple task. So, while creating your own solution may be fun, it should be left at that, just for fun!