Using JSX for a better developer experience

99% of this website is statically generated HTML using my SSG which I've been modifying and maintaining since 2019. What started as a Bash script, has grown immensely since first starting and the final output has not really changed too much. However, sometimes, I like to put up cool interactive pages on my site like my Music page, Games page, or even the Mahjong scoring page.

However, there are only so many times you can use document.createElement(); before you go insane.

How scripts work on my site

I write all my scripts in Typescript. Using Esbuild, I bundle these scripts into individual minified js files. My custom SSG tool handles hot reloads and can do all this on the fly, so there's no real friction in using Typescript over plain Javascript.

Every page on my site is defined as a Markdown file, using the front matter I can set the title, summary, publish date etc. but also include a list of scripts. By doing this, over a central script.js file (although I do have one), I can keep load times for my site low since pages will only load the scripts essential for them to function.

How I initially did interactive pages

When I first was adding JS & Interactive pages to my site, pages would look something like this:

---
title: example
scripts: old/example.ts
---

Hello World

<div class="example">

</div>
window.addEventListener('DOMContentLoaded', () => {
  const target: HTMLDivElement | null = [
    ...(document.getElementsByClassName("example") || [])
  ]?.[0]
  
  if (target) {
    const p = document.createElement("p");
    p.innerText = "Hello World"
    
    target.append(p)
  }
})

While this worked, the developer experience was quite tedious. Especially with larger components. I eventually wrote this function to somewhat assist with element creation:

export const e = <T extends keyof HTMLElementTagNameMap>(
  tag: T,
  attributes: Attributes<T> | null,
  children: (string | HTMLElement)[] | string | HTMLElement = [],
) => {
  const element = document.createElement(tag);
  Object.entries(attributes || {}).forEach(([key, value]) => {
    const castKey = key as keyof HTMLElementTagNameMap[T];
    element[castKey] = value;
  });

  if (Array.isArray(children)) {
    children.forEach((child) => {
      element.append(child);
    });
  } else {
    element.append(children);
  }

  return element;
};

Which turned the previous code into something like this:

import {e} from '../element';

window.addEventListener('DOMContentLoaded', () => {
  // fetch target ... 
  
  if (target) {
    target.append(e('p', {}, "Hello World"))
  }
})

This was great, and a big leap in dev QOL. But I really, really, really, wanted to use something as slick as JSX. Turns out - It's mostly just a tweak in my ts config. I could even re-use the utility function I had written before!

Using TSX

By setting the jsxFactory property in my config to the function e, when typescript/esbuild compiles a file - instead of inserting a React specific React.createElement(), it'll insert my function instead.

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "jsx",
  },
}

Without setting jsx to react pretty much every linter will complain when using TSX. I also needed to create a type definition in my project root, otherwise I wouldn't get any typing. You technically get this for free if I installed React as a dependency but that's overkill.

/// <reference lib="DOM" />

declare namespace JSX {
  type Element = HTMLElement;

  interface IntrinsicElements extends IntrinsicElementMap {}

  type IntrinsicElementMap = {
    [K in keyof HTMLElementTagNameMap]: {
      [k: string]: any;
    };
  };

  interface Component {
    (properties?: { [key: string]: any }, children?: Node[]): Node;
  }
}

I ended up renaming e to JSX for easier importing and now the above script looks like this:

import JSX from '../jsx' 

window.addEventListener('DOMContentLoaded', () => {
  // fetch target ... 
  
  if (target) {
    target.append(<p>Hello World</p>)
  }
})

I genuinely don't know why I didn't do this sooner - using JSX for the interactive parts of my site is so much nicer than what I was doing before.

import JSX from '../jsx';

const postsList = (
  <ul className="postsList">
    {...posts.map((post) => (
      <li>
        {post.emoji || '📝'}
        <a href={post.link.replace('https://pfy.ch', '')}>
          <span>
            <span className="date">
              {post.date.toLocaleDateString()} -{' '}
            </span>
            {post.title.replaceAll('&amp;', '&')}
          </span>
        </a>
      </li>
    ))}
  </ul>
);

vs

import { e } from '../element';

const postsList = e(
  'ul',
  { className: 'postsList' },
  posts.map((post) =>
    e('li', {}, [
      `${post.emoji || '📝'} `,
      e('a', { href: post.link.replace('https://pfy.ch', '') }, [
        e('span', {}, [
          e(
            'span',
            { className: 'date' },
            `${post.date.toLocaleDateString()} - `,
          ),
          post.title.replaceAll('&amp;', '&'),
        ]),
      ]),
    ]),
  ),
);


This page was originally published 17 Mar 2025

Updated 215 days ago (17 Mar 2025)