Building a Single Page App Doesn't Have to be Complex

December 29, 2023

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:

    1. The component can be declaratively described in the markup, and props can be passed to the component from html using html attributes.
    2. 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:

  1. preact (11 kb)
  2. preact/hooks (3.7 kB)
  3. htm (1.21 kB)
  4. preact-custom-element (2.57 kB)

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:

  1. alpine@3.13.3 (43.4 kB)
  2. htmx@1.9.10 (47.8 kB)
  3. jquery@4.0.0-beta/slim ( 56.3 kB)
  4. 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:

  1. TypeScript (which prevents errors, and provides a better editing experience via Intellisense).
  2. 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).
  3. Minification and bundling (which reduces the bandwidth and the number of network requests that the app uses).
  4. 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).
  5. 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:

  1. package.json (configures the project)
  2. tsconfig.json (configures TypeScript)
  3. src/pages/counter.tsx (the actual Preact component)
  4. src/server.tsx (a small server which serves dynamic html, and static assets)
  5. 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)
  6. src/utils/renderUtils.ts (two small utility functions for generating html)

The setup will also include the following five dependencies:

  1. hono (a miniscule server-side router)
  2. @hono/node-server (an adapter for using hono with node, since hono supports all js runtimes)
  3. wouter-preact (a small client-side router)
  4. preact
  5. 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):

  1. esbuild (a fast bundler and minifier written in Go)
  2. tsx (a utility for directly running TypeScript without compiling it first)
  3. 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')

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]+'
  1. @hono/node-server
  2. esbuild
  3. hono
  4. preact-render-to-string
  5. preact
  6. tsx
  7. typescript
  8. wouter-preact
  9. @esbuild/<OS>-<ARCH> (this will vary depending on your machine’s specs)
  10. pretty-format
  11. fsevents
  12. get-tsconfig
  13. mitt
  14. regexparam
  15. resolve-pkg-maps
Listing the Production Dependencies
npm ls --all --omit dev --parseable | sed -E -e "s|$PWD(/node_modules)?/?||" | grep -E '[^\s]+'
  1. @hono/node-server
  2. hono
  3. preact-render-to-string
  4. preact
  5. wouter-preact
  6. pretty-format
  7. mitt
  8. 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)

Footnotes

  1. 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 .

Join the Newsletter

Subscribe to get my latest content by email!

I won't send you spam, and I won't share your email address.

You can unsubscribe at any time.