Building a Single Page App Doesn't Have to be Complex
December 29, 2023Table Of Contents
- You don't need a build step and hundreds of dependencies to build an SPA.
Introduction
At one point, adding a script tag to your document was considered the standard way of using a frontend framework. Developers were transitioning from imperatively updating the DOM with jQuery to using reactive frameworks like AngularJS, and it would take several years for React’s JSX, Vue’s single file components, and Angular 2’s extensive TypeScript usage to popularize the build step. It’s worth noting though that you can still program this way. Not only do Vue, React, and Preact (a tiny drop-in React replacement) still support this method of frontend programming, but it’s actually become more tolerable with ESM, import maps, and tagged templates.
Building a Preact App Without Build Tools
Preact is my go-to library in any scenario where I’d normally reach for React. It has the exact same api as React at a fraction of React DOM’s bundle size. Here’s a full working example of a Preact app in a single html file:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Preact Demo</title>
<meta
name="description"
content="This is a demo of a Preact web component without any build tools." />
<meta name="author" content="Keith Brooks" />
<style type="text/css">
x-counter::part(wrapper),
x-counter:not(:defined) > * {
display: flex;
justify-content: space-between;
width: 10rem;
}
</style>
<script type="importmap">
{
"imports": {
"preact": "https://unpkg.com/preact@10.19.3/dist/preact.mjs",
"htm": "https://unpkg.com/htm@3.1.1/dist/htm.module.js?module",
"preact/hooks": "https://unpkg.com/preact@10.19.3/hooks/dist/hooks.mjs",
"preact-custom-element": "httpswf://unpkg.com/preact-custom-element@4.3.0/dist/preact-custom-element.esm.js"
}
}
</script>
<script type="module">
import { h } from 'preact'
import { useReducer } from 'preact/hooks'
import htm from 'htm'
import register from 'preact-custom-element'
const html = htm.bind(h)
function Counter({ count: countProp = 0 }) {
const [count, add] = useReducer((a, b) => a + b, Number(countProp))
return html`
<div part="wrapper">
<input readonly value=${count} />
<button onClick=${() => add(-1)}>Decrement</button>
<button onClick=${() => add(1)}>Increment</button>
</div>
`
}
register(Counter, 'x-counter', ['count'], { shadow: true })
</script>
</head>
<body>
<x-counter count="0">
<div>
<input readonly value="0" />
<button>Decrement</button>
<button>Increment</button>
</div>
</x-counter>
</body>
</html>
Explaining the Code
There are a few aspects of this file that are worth explaining.
-
Instead of using JSX I’m using a JSX-like package called htm, which allows you to declaratively express DOM updates using a tagged template function.
-
The Preact component is functioning as a web component using another package called preact-custom-element, which as the name implies, allows you to define a custom HTML element. The advantages of this approach are two-fold:
- The component can be declaratively described in the markup, and props can be passed to the component from html using html attributes.
- Fallback content can be passed as a child of the web component which the browser will render before the JS is loaded (or if the JS fails to load altogether). This also has the added benefit of preventing a flash of unstyled content before Preact takes over [1]. The web component can be styled using the css
::part
pseudoselector, and the fallback content can be styled using the:defined
pseudoselector.
Size Statistics
What’s interesting about this setup is that it requires very little JavaScript. This setup uses a total of 18.48 kb of JavaScript, and that doesn’t even factor in the effect of gzipping. Here are the constituent packages and their sizes:
If I truly wanted to code golf, I could’ve ditched the htm dependency and just used Preact’s h
function directly, and I also could’ve omitted preact-custom-element and simply given Preact a div to render to. That would’ve reduced the total size down to 14.7 kb. To put that in perspective, here are the sizes of some popular packages which are often used in lieue of an SPA framework:
- alpine@3.13.3 (43.4 kB)
- htmx@1.9.10 (47.8 kB)
- jquery@4.0.0-beta/slim ( 56.3 kB)
- jquery@4.0.0-beta (79 kB)
Adding a Tiny Build Step and SSR Server
This is a bit of a bait and switch given the title of the article, but I wanted to include a small example of a minimal build step and SSR server, just to give a sense of what sort of quality of life improvements they provide, and to dispel the notion that a build step necessitates hundreds of dependencies. I’ll limit myself to no more than 15 dependencies (including indirect transitive dependencies and dev dependencies). Here are some features which a build step (and a minimal server) will afford us:
- TypeScript (which prevents errors, and provides a better editing experience via Intellisense).
- JSX (which allows us to omit the htm dependency, and also improves editor support since we’re no longer programming with a string-based DSL).
- Minification and bundling (which reduces the bandwidth and the number of network requests that the app uses).
- Server-side and client-side routing (the page will initially be rendered based on routes matched on the server, and the component can then update the history stack locally in the user’s browser).
- SSR (which programmatically generates the initial markup, and obviates the need for the preact-custom-element dependency).
You could go a step further and add linting to this setup to catch errors that even TypeScript can’t, but I’ve avoided this due to my dependency requirements.
Overview
The entire setup in all its glory will only contain the following six files:
- package.json (configures the project)
- tsconfig.json (configures TypeScript)
- src/pages/counter.tsx (the actual Preact component)
- src/server.tsx (a small server which serves dynamic html, and static assets)
- src/utils/hydrate.tsx (a small utility which renders the component and handles hydration, which is the process of attaching event listeners to the server-rendered markup)
- src/utils/renderUtils.ts (two small utility functions for generating html)
The setup will also include the following five dependencies:
- hono (a miniscule server-side router)
- @hono/node-server (an adapter for using hono with node, since hono supports all js runtimes)
- wouter-preact (a small client-side router)
- preact
- preact-render-to-string (renders the component on the server and returns a string of html)
We’ll also be utilizing the following three dev dependencies (these are only necessary in development and aren’t required in production):
- esbuild (a fast bundler and minifier written in Go)
- tsx (a utility for directly running TypeScript without compiling it first)
- typescript
You could technically omit tsx and simply compile the api first before running it, but tsx has very few dependencies and running code via tsx --watch
provides a much better dev experience.
The Actual Code
I’ve included all of the six files here as tabs (click on a filename to select a tab). All of the code is available on Github. If you’d like to see a version of this project that’s fully compatible with the React ecosystem, I’ve included a branch on Github that aliases react and react-dom to preact/compat.
The server
This is a fairly standard JS api, but there are a few nuances worth discussing.
- Hono appears to be using a feature of TypeScript called template literal types, which allow you to make type assertions about the contents of strings. So for example, if you were to misspell the name of the query parameter
c.req.param('initialValue')
, TypeScript would raise an error. - On the server, the routing library (
wouter-preact
) has no way of determining what the current route is, which is why the component is being wrapped in a<Router>
context provider.
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { html } from './utils/renderUtils.js'
import render from 'preact-render-to-string'
import Counter from './pages/counter.js'
import { Router } from 'wouter-preact'
const app = new Hono()
app.get('/counter/:initialValue', c => {
const count = c.req.param('initialValue')
return c.html(
html({
title: 'Preact Demo',
description: 'This is a demo of a Preact web component.',
scriptName: 'counter.js',
html: render(
<Router ssrPath={`/counter/${count}`}>
<Counter />
</Router>
),
initialState: {},
})
)
})
app.use('/public/*', serveStatic({ root: './' }))
serve({ fetch: app.fetch, port: 3000 })
console.log('listening on port 3000')
The preact component
This file contains the actual Preact component that’s rendered on the server and in the browser. It’s mostly the same as the single file example with the notable exception of client side routing.
- Instead of storing the component’s state using the
useState
hook, we’re storing the state in the url. - The current count is retrieved via a url param, and when the count changes we update it with the
setLocation
function returned from theuseLocation
hook. - The string-based htm DSL has been replaced with JSX, which is just a thin layer of syntactic sugar on top of calls to Preact’s
h
function. If we were using React, this would instead get transformed into calls toReact.createElement
. Not only does this slightly improve bundle size by removing the htm dependency, but it’s much more type safe and aligns better with editor tooling.
import { useCallback, useMemo } from 'preact/hooks'
import clientHydrate from '../utils/hydrate.js'
import { useLocation, useRoute } from 'wouter-preact'
export default function Counter() {
const [, params] = useRoute('/counter/:currentCount')
const [, setLocation] = useLocation()
const numberCount = useMemo(
() => (params?.currentCount ? Number(params.currentCount) : 0),
[params]
)
const add = useCallback(
(n: number) => setLocation(`/counter/${numberCount + n}`),
[setLocation, numberCount]
)
return (
<div className='wrapper'>
<input readOnly value={numberCount} />
<button onClick={() => add(-1)}>Decrement</button>
<button onClick={() => add(1)}>Increment</button>
</div>
)
}
clientHydrate(Counter)
Hydration
With the introduction of SSR we now have an additional step to perform on the client-side. This is what the workflow looks like:
- The server renders the initial markup based on the value of the request parameter.
- On the client, we render the component based on the initial state received from the server (this gets encoded as JSON in a script tag with the id
__INITIAL_DATA__
). - Preact attaches event handlers to the server-rendered markup. If this were a pure client-side app without SSR, you would simply call Preact’s
render
function rather thanhydrate
, and Preact would have to construct the html elements from scratch.
import { hydrate, ComponentType } from 'preact'
export default function clientHydrate<T>(
Component: ComponentType<T>,
propsId = '__INITIAL_DATA__'
) {
if ('location' in globalThis) {
const propsElement = document.getElementById(propsId)
const props = JSON.parse(propsElement!.textContent ?? '{}')
hydrate(
<Component {...props} />,
document.getElementById('root') as HTMLElement
)
}
}
Render Utility Functions
This file exports two utility functions necessary for generating the server rendered markup.
- The html function simply interpolates dynamic values into an html shell.
- The interesting part is the
escapeJson
function. This function receives a JSON string with the component’s initial state and escapes problematic characters. I wrote this function by examining the NextJS source code. - The escaped json is inserted into the dom in a script tag, which the hydrate function will parse to provide to the component. In this simple example we don’t have any initial state, but it’s important enough that I thought it was worth demonstrating.
export const html = ({
title,
description,
scriptName,
html,
initialState = {},
scriptId = '__INITIAL_DATA__',
}: Required<Record<'title' | 'description' | 'scriptName' | 'html', string>> & {
scriptId?: string
initialState?: unknown
}) =>
`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<meta
name="description"
content="${description}" />
<meta name="author" content="Keith Brooks" />
<style type="text/css">
.wrapper {
display: flex;
justify-content: space-between;
width: 10rem;
}
</style>
<script id="${scriptId}" type="application/json">${escapeJson(
JSON.stringify(initialState ?? {})
)}</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/public/${scriptName}"></script>
</body>
</html>
`.trim()
/**
* This utility is based on https://github.com/vercel/next.js/blob/9bcf678e646fea8340a8d9c72094c6c624d71668/packages/next/server/htmlescape.ts#L14
*/
function escapeJson(s: string) {
return s
.replaceAll('&', '\\u0026')
.replaceAll('>', '\\u003e')
.replaceAll('<', '\\u003c')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029')
}
The package.json file
This file lists the app’s dependencies and provides a set of scripts that can be run using npm. To start the app in development mode for example you’d run npm run dev
. I will briefly explain the scripts and their purposes below:
- start: This is the script you’d run to start the app in production. This is such a common script in the JS ecosystem that you can just run
npm start
rather thannpm run start
to run this script. It compiles the TypeScript api into plain JS using the TypeScript compiler (tsx
), bundles the client JS, and then runs the server using Node. - dev: This runs the server in watch mode using
tsx
, so that the server is restarted whenever any of the source files changes. It also runsesbuild
in watch mode, which repeatedly builds the client bundle whenever the client source code changes. - check: This runs the type checker once to find errors.
- check:watch: This runs the type checker in watch mode.
The rest of the scripts aren’t meant to be run directly, but are dependend upon by the start and dev scripts.
{
"name": "preact-app",
"version": "1.0.0",
"description": "A barebones Preact, ESBuild, TypeScript example.",
"main": "dist/index.js",
"type": "module",
"author": "Keith Brooks",
"scripts": {
"start": "npm run build && node dist/server.js",
"dev": "npm run client:watch & npm run server:watch",
"check": "tsc --noEmit",
"check:watch": "tsc --noEmit --watch",
"client:watch": "esbuild src/pages/counter.tsx --bundle --watch=forever --outdir=public --sourcemap --define:IS_DEV=true",
"server:watch": "tsx watch src/server.tsx",
"clean": "rm -rf {dist,public}/*",
"build": "npm run clean && npm run build:client & npm run build:server & wait",
"build:client": "esbuild src/pages/counter.tsx --bundle --minify --outfile=public/counter.js --define:IS_DEV=false",
"build:server": "tsc"
},
"devDependencies": {
"esbuild": "^0.19.10",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
},
"dependencies": {
"@hono/node-server": "^1.3.3",
"hono": "^3.11.9",
"preact": "^10.19.3",
"preact-render-to-string": "^6.3.1",
"wouter-preact": "^3.0.0-rc.2"
}
}
The TypeScript Config
This is the file that configures TypeScript. The TypeScript docs have an exhaustive list of every option, and it’s worth perusing. Despite the json file extension, the config is actually written in a superset of json that helpfully allows comments. You can generate a heavily commented tsconfig stater by running the TypeScript compiler with the --init
option (e.g. npx tsc --init
).
{
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
}
Analyzing the App and Its Dependencies
I promised that the app would depend on no more than 15 packages.
So how do we actually analyze the node_modules folder and see what has been installed? Thankfully, npm provides a plethora of ways to analyze your dependencies. You can run commands like npm why <PACKAGE_NAME>
to figure out why a given package was installed, and you can even use a css-like query selector syntax to query your dependencies.
Analyzing the node_modules Folder
The most common tool you’ll end up using however is a command called npm ls
, which (much like the UNIX command ls) lists your dependencies. This will only list direct dependencies rather than transitive dependencies. To list all of the project’s dependencies you’d have to run npm ls --all
. When you run this command it’ll print out a list of dependencies in a tree-like format. You can have it output the tree as json as well using the --json
option, or as a flat easily parseable list using the --parseable
or -p
options.
ESBuild is written in Go and therefore gets compiled down to a set of static binaries (one for each os and cpu architecture combination). Each of these binaries is listed as an optional dependency, but NPM will only download the one that matches your machine’s os and cpu architecture. To filter out these optional dependencies you can run npm ls --all | grep -v 'UNMET OPTIONAL DEPENDENCY'
, which should output something similar to this (depending on your machine):
preact-app@1.0.0
├── @hono/node-server@1.3.3
├─┬ esbuild@0.19.10
│ ├── @esbuild/linux-arm64@0.19.10
├── hono@3.11.9
├─┬ preact-render-to-string@6.3.1
│ ├── preact@10.19.3 deduped
│ └── pretty-format@3.8.0
├── preact@10.19.3
├─┬ tsx@4.7.0
│ ├── esbuild@0.19.10 deduped
│ ├── fsevents@2.3.3
│ └─┬ get-tsconfig@4.7.2
│ └── resolve-pkg-maps@1.0.0
├── typescript@5.3.3
└─┬ wouter-preact@3.0.0-rc.2
├── mitt@3.0.1
├── preact@10.19.3 deduped
└── regexparam@3.0.0
You can see that several of these packages have duplicate dependencies, but npm is intelligent enough to deduplicate them, hence the ‘deduped’ identifier.
Counting the Dependencies
In the follwing examples I’ve piped npm ls into grep and sed. The sed and grep commands are used to remove the leading $PWD/node_modules/
string from the output (wich adds unnecessary visual noise). NPM ls lists your project itself not just third party packages, and this filters that first line out as well.
Listing All the Dependencies
npm ls --all --parseable | sed -E -e "s|$PWD(/node_modules)?/?||" | grep -E '[^\s]+'
- @hono/node-server
- esbuild
- hono
- preact-render-to-string
- preact
- tsx
- typescript
- wouter-preact
- @esbuild/<OS>-<ARCH> (this will vary depending on your machine’s specs)
- pretty-format
- fsevents
- get-tsconfig
- mitt
- regexparam
- resolve-pkg-maps
Listing the Production Dependencies
npm ls --all --omit dev --parseable | sed -E -e "s|$PWD(/node_modules)?/?||" | grep -E '[^\s]+'
- @hono/node-server
- hono
- preact-render-to-string
- preact
- wouter-preact
- pretty-format
- mitt
- regexparam
Analyzing the Client Build
npx esbuild src/pages/counter.tsx \
--bundle \
--minify \
--analyze
This will output a list of all of the constituent modules and their sizes. It should look something like this:
counter.js 16.7kb 100.0%
├ node_modules/preact/dist/preact.module.js 10.4kb 62.4%
├ node_modules/preact/hooks/dist/hooks.module.js 2.8kb 17.0%
├ node_modules/wouter-preact/esm/index.js 969b 5.7%
├ node_modules/wouter-preact/esm/use-browser-location.js 691b 4.0%
├ node_modules/wouter-preact/esm/preact-deps-dec5c677.js 551b 3.2%
├ node_modules/regexparam/dist/index.mjs 443b 2.6%
├ node_modules/preact/jsx-runtime/dist/jsxRuntime.module.js 359b 2.1%
├ src/pages/counter.tsx 325b 1.9%
└ src/utils/hydrate.tsx
Bonus: Styling the App with Goober
There’s absolutely nothing wrong with styling your markup using plain stylesheets, but if you want to define your styles from within your component files, and change your styling dynamically based on the state of your app, then you can use a css-in-js library called Goober. Goober is a 1kb package with a single peer dependency called csstype (which is used to provide css type information and has no dependencies of its own), so it won’t drastically inflate the dependency tree. You might also want to install the styled-components VSCode extension for better editor support. The full source code for this version is on Github under the Goober branch. If you want live-reload functionality, there’s a branch for that too.
Styling the Component with Goober
Goober provides an API that should look familiar to you if you’ve ever used css-in-js packages like styled-components or emotion. You can generate a css class name directly by calling the css
function, or you can simply used the styled
function to create a small wrapper component with your styles applied to it.
One gotcha to be aware of when using the css
function with SSR is that you have to delay the execution of the css function to prevent it from being called at import-time rather than at render-time. You can define a small thunk to delay its execution.
Do this:
const divClassName = () => css`
width: 5rem;
`
const App = () => <div className={divClassName()} />
Or this:
const App = () => <div className={css`width: 5rem;`} />
Instead of this:
const divClassName = css`
width: 5rem;
`
const App = () => <div className={divClassName} />
This is only required if you have an SSR server that’s handling multiple concurrent requests, and you’re using the css
function instead of the styled
function. You’ll end up defining a function anyway if you want to interpolate props into the css function’s tagged template string.
import { useCallback, useMemo } from 'preact/hooks'
import clientHydrate from '../utils/hydrate.js'
import { useLocation, useRoute } from 'wouter-preact'
import { h } from 'preact'import { setup, css, styled } from 'goober'setup(h)
export default function Counter() {
const [, params] = useRoute('/counter/:currentCount')
const [, setLocation] = useLocation()
const numberCount = useMemo(
() => (params?.currentCount ? Number(params.currentCount) : 0),
[params]
)
const add = useCallback(
(n: number) => setLocation(`/counter/${numberCount + n}`),
[setLocation, numberCount]
)
return (
<div className={containerClass({ width: '10rem' })}> <Input readOnly value={numberCount} /> <Button onClick={() => add(-1)}>Decrement</Button> <Button onClick={() => add(1)}>Increment</Button> </div> )
}
const Input = styled('input')` padding: 0.25rem;`const Button = styled('button')` cursor: pointer;`const containerClass = ({ width }: { width: string | number }) => css` display: flex; justify-content: space-between; width: ${width}; & * { margin: 1rem; } & *:first-child { margin-left: 0; } & *:last-child { margin-right: 0; }`
clientHydrate(Counter)
Extracting the Initial CSS with Goober
This is pretty straightforward. When you style components with Goober it caches all of those styles in an object, and when you call the extractCss
function it outputs them as a string. The styles have to be added to a style tag with the id of _goober
so that Goober can identify them on the client.
import { extractCss } from 'goober'
export const html = ({
title,
description,
scriptName,
html,
initialState = {},
scriptId = '__INITIAL_DATA__',
}: Required<Record<'title' | 'description' | 'scriptName' | 'html', string>> & {
scriptId?: string
initialState?: unknown
}) =>
`
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
<meta
name="description"
content="${description}" />
<meta name="author" content="Keith Brooks" />
<style id="_goober">${extractCss()}</style> <script id="${scriptId}" type="application/json">${escapeJson(
JSON.stringify(initialState ?? {})
)}</script>
</head>
<body>
<div id="root">${html}</div>
<script src="/public/${scriptName}"></script>
</body>
</html>
`.trim()
/**
* This utility is based on https://github.com/vercel/next.js/blob/9bcf678e646fea8340a8d9c72094c6c624d71668/packages/next/server/htmlescape.ts#L14
*/
function escapeJson(s: string) {
return s
.replaceAll('&', '\\u0026')
.replaceAll('>', '\\u003e')
.replaceAll('<', '\\u003c')
.replaceAll('\u2028', '\\u2028')
.replaceAll('\u2029', '\\u2029')
}
Footnotes
-
You could also do something similar using a new standard called the declarative shadow dom, but at the time of writing this standard hasn’t been implemented in Firefox yet . ↩