Skip to content

JSX with Vanilla JavaScript

With just a small bit of configuration, you can incorporate JSX markup in your “vanilla” JavaScript project. This is thanks, in large part, to Vite’s use of Oxc.

  1. Create a bare-bones project using Vite.

    Terminal window
    pnpm create vite@latest vanilla-jsx -- --template vanilla
    cd vanilla-jsx
    pnpm install
  2. The stock template produces a page with a snazzy image at the top.

    The out-of-the-box web page
  3. Create the Vite configuration file with the following contents. Since Vite uses Oxc as the JSX transpiler, we can inform Oxc that we want to use the “classic” runtime (instead of React) via a function we’ll call h.

    vite.config.js
    import { defineConfig } from 'vite';
    export default defineConfig({
    oxc: {
    jsx: {
    runtime: 'classic',
    pragma: 'h',
    },
    },
    });

    We can call our function anything; the choice of h is just a traditional name based on the old hyperscript package.

  4. Create a small JavaScript function - h - that we told Oxc to use during transpilation.

    src/h.js
    export function h(tag, props, ...children) {
    const element = document.createElement(tag);
    for (const [name, value] of Object.entries(props ?? {})) {
    if (name.startsWith('on') && typeof value === 'function') {
    element.addEventListener(
    name.slice(2).toLowerCase(),
    value
    );
    } else {
    element.setAttribute(name, value);
    }
    }
    element.append(...children.flat());
    return element;
    }
  5. In any JavaScript file where you want to write JSX, import h and write your markup. For example, here’s a banner for announcements.

    src/announcement.jsx
    import { h } from './h';
    export function announcement(message) {
    return <div class="banner">{message}</div>;
    };
  6. Use the function in your main.js.

    main.js
    import './style.css'
    import heroImg from './assets/hero.png'
    import javascriptLogo from './assets/javascript.svg'
    import viteLogo from './assets/vite.svg'
    import { setupCounter } from './counter.js'
    import { announcement } from './announcement.jsx';
    document.body.prepend(announcement('We have evolved!'));
  7. Add a little styling to your CSS, and you’re good to go.

    style.css
    #app {
    width: 1126px;
    max-width: 100%;
    margin: 0 auto;
    text-align: center;
    border-inline: 1px solid var(--border);
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    }
    .banner {
    width: 1126px;
    max-width: 100%;
    margin: 0 auto;
    text-align: center;
    border-inline: 1px solid var(--border);
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    background-color: gold;
    font-weight: 500;
    }
  8. Here’s your final result.

    Our simple JSX implementation
  1. Edit the Vite configuration to tell Oxc to support fragments (<></>).

    vite.config.js
    import { defineConfig } from 'vite';
    export default defineConfig({
    oxc: {
    jsx: {
    runtime: 'classic',
    pragma: 'h',
    pragmaFrag: 'Fragment',
    },
    },
    });
  2. Provide our fragment handling.

    src/h.js
    export const Fragment = Symbol('Fragment');
    export function h(tag, props, ...children) {
    if (tag === Fragment) {
    const fragment = document.createDocumentFragment();
    fragment.append(...children.flat());
    return fragment;
    }
    const element = document.createElement(tag);
    for (const [name, value] of Object.entries(props ?? {})) {
    if (name.startsWith('on') && typeof value === 'function') {
    element.addEventListener(
    name.slice(2).toLowerCase(),
    value
    );
    } else {
    element.setAttribute(name, value);
    }
    }
    element.append(...children.flat());
    return element;
    }
  3. Remember to import Fragment alongside h, and you can start writing empty fragment tags as your “wrapper”.

    src/announcement.jsx
    import { h } from './h';
    import { h, Fragment } from './h';
    export function announcement(message) {
    return <div class="banner">{message}</div>
    return <>
    <div class="banner">{message}</div>
    <div class="banner">TADA! Fragments!</div>
    </>
    };
  4. If we want, we can tighten up our h.js code a little more.

    src/h.js
    export const Fragment = Symbol('Fragment');
    export function h(tag, props, ...children) {
    const node =
    tag === Fragment
    ? document.createDocumentFragment()
    : document.createElement(tag);
    if (tag !== Fragment) {
    for (const [name, value] of Object.entries(props ?? {})) {
    if (name.startsWith('on') && typeof value === 'function') {
    node.addEventListener(name.slice(2).toLowerCase(), value);
    } else {
    node.setAttribute(name, value);
    }
    }
    }
    node.append(...children.flat());
    return node;
    }

At this point, we’ve got three clean JSX concepts working nicely in our “vanilla” JavaScript app.

// HTML elements
<h1>Hello</h1>
// nesting
<section>
<h1>Hello</h1>
<p>World</p>
</section>
// fragments
<>
<h1>Hello</h1>
<p>World</p>
</>

While our announcement() function uses JSX internally, our main.js is still taking responsibility to call it as a function. React goes farther than what we’ve done so far by making Function Components possible. We’ll make a few more adaptations so that we can write <Announcement message='We have evolved!'></Announcement>.

  1. Update your h.js to detect if the tag parameter is a function.

    src/h.js
    export const Fragment = Symbol('Fragment');
    export function h(tag, props, ...children) {
    if (typeof tag === 'function') {
    return tag(props ?? {}, children);
    }
    // the rest of the code stays the same...
  2. Modify your announcement.jsx to the following.

    src/announcement.jsx
    import { h, Fragment } from './h';
    export function announcement(message) {
    export function Announcement(props) {
    return <>
    <div class="banner">{message}</div>
    <div class="banner">{props.message}</div>
    <div class="banner">TADA! Fragments!</div>
    </>
    };

    Notice how we’ve also changed our function name to be Announcement. This naming convention is important because our JSX compiler needs to recognize that <Announcement> is not just a string. It also follows how React names its components.

  3. Let’s rename our main.js to main.jsx. Then, import the h and Fragment. Now our component looks very React-like!

    main.jsx
    import './style.css'
    import heroImg from './assets/hero.png'
    import javascriptLogo from './assets/javascript.svg'
    import viteLogo from './assets/vite.svg'
    import { setupCounter } from './counter.js'
    import {h, Fragment} from './h.js';
    import { announcement } from './announcement.jsx';
    import { Announcement } from './announcement.jsx';
    document.body.prepend(announcement('We have evolved!'));
    document.body.prepend(<Announcement message='We have evolved!'></Announcement>);

    Notice that the file rename will break our project unless we also update our HTML.

    index.html
    <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
    <script type="module" src="/src/main.jsx"></script>
    </body>

Don’t be fooled. While this might look like a very attractive alternative to using React, we haven’t come anywhere near to all the capabilities React provides. So don’t expect this to be some sort of “drop-in” replacement. React is very powerful, and it’s worth your time to explore!