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('&', '&')}
</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('&', '&'),
]),
]),
]),
),
);