The single-page app (SPA) has taken over the modern web with advantages and drawbacks.
I don't think I will write a pure SPAs again any time soon. I wish to access the web platform directly, and utilize more advanced tools when needed.
The multi-page apps (MPA) with their HTML-first approach are gaining momentum again with good reason.
With MPAs (PHP, MVC frameworks), you get quick page loads, greater simplicity and less flaky Node.js dependencies.
But you might occationally still want the client-side state that React, Svelte, Elm or Vue provide.
Islands archtecture
This is where the islands architecture is a good solution. Just create small apps/widgets for certain parts of the page.
The drawback is often that you need a bundling step to get it working.
One of the lowest barriers to that goal might be Preact
(or React) and htm
in a vanilla JS file. No bundler, no node.js, no transpiling/compiling.
You even get the hooks API!
Preact with hooks and a lightweight alternative to JSX
Preact market themselves as a "3kB alternative to react with the same modern API". It can pretty much give you all you need from React, but with lower baggage.
If we start with an index.html
HTML file, the entrypoint we need is highlighted below:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quick Preact with Hooks</title>
</head>
<body>
<div class="counter" data-initial-value="1"></div>
<script src="./counter.module.js" type="module"></script>
</body>
</html>
The <div>
element with class="counter"
will be the node that the counter.module.js
file will attach itself to.
Note that I am setting the initial values for this component via data-attributes directly on the<div>
element.
Setting initial values via your templating engine should be effortless through data attributes. It also saves you from writing HTTP requests.
Also note type=module
in the script tag. This means that the js file is a JavaScript module.
Skypack instead of npm
Skypack gives you access to npm libraries pre-bundled.
In a JavaScript module, we can import libraries directly from Skypack.
These are the imports we will use throughout this tutorial.
import { useState, useEffect } from "https://cdn.skypack.dev/preact@10/hooks";
import { render, html } from "https://cdn.skypack.dev/htm@3/preact";
The import
syntax is available now in vanilla JavaScript and supported by all modern browsers!
As you see in the paths, you are free to define version like preact@10
or even preact@^10.5.0
for protection from breaking changes.
htm
instead of JSX
htm
, or Hyperscript Tagged Markup, is a way of writing views in JSX-like syntax without any transpiling or compiling.
You can do the same with React, but I will use Preact since it's mostly a better fit for this use-case.
Let's create the counter.module.js
file the HTML file we made above expects. First without any state logic:
import { render, html } from "https://cdn.skypack.dev/htm@3/preact";
function Counter({ initialValue }) {
return html`
<h1>Counter</h1>
<div>${initialValue}</div>
<button>Increase</button>
<button>Decrease</button>
`;
}
const appElement = document.querySelector(".counter");
render(
html`<${Counter} initialValue=${appElement.dataset.initialValue} />`,
appElement
);
The html
function uses template literals. This enables us to interpolate javascript like this: ${}
in place of the JSX brackets {}
.
Other than that, it's almost identical to how it works with React and JSX!
Rendering many of the same widget
When working with a multi-page app, you might also want to initialize the same widget many places.
You can improve the render logic by iterating over every element with the given class. This is why I use class
over id
.
const appElement = document.querySelector(".counter");
render(
html`<${Counter} initialValue=${appElement.dataset.initialValue} />`,
appElement
);
const appElements = document.querySelectorAll(".counter");
appElements.forEach((appElement) => {
render(
html`<${Counter} initialValue=${appElement.dataset.initialValue} />`,
appElement
);
});
This way, you can add unlimited widgets on the same page, and initialize with different values like this:
<div class="counter" data-initial-value="1"></div>
<div class="counter" data-initial-value="10"></div>
<script src="./counter.module.js" type="module"></script>
Each widget will have their own state without being dependent on each other. They are initialized with their respective initial values.
Making components
Splitting the widget into smaller components is easy. Let's make a very trivial example by creating a reusable component out of the h1
element.
function Title({ title }) {
return html` <h1>${title}</h1> `;
}
function Counter({ initialValue }) {
return html`
<${Title} title="Counter" />
// ...etc
`;
}
Very simple!
Adding hooks
The hooks API is available from the preact library by importing them from preact/hooks
. This gives you the hooks API with all it's benefits.
import { useState, useEffect } from "https://cdn.skypack.dev/preact@10/hooks";
import { render, html } from "https://cdn.skypack.dev/htm@3/preact";
function Title(props) {
const { title } = props;
return html` <h1>${title}</h1> `;
}
function Counter(props) {
const { initialValue } = props;
const [count, setCount] = useState(Number(initialValue));
const increaseCount = () => setCount(count + 1);
const decreaseCount = () => setCount(count - 1);
useEffect(() => {
console.log(`Count changed to ${count}`);
}, [count]);
return html`
<${Title} title="Counter" />
<div>${count}</div>
<button onclick=${increaseCount}>Increase</button>
<button onclick=${decreaseCount}>Decrease</button>
`;
}
const appElements = document.querySelectorAll(".counter");
appElements.forEach((appElement) => {
render(
html`<${Counter} initialValue=${appElement.dataset.initialValue} />`,
appElement
);
});
Custom hooks
It can often be a good idea to organize a collection of hooks into one custom hook. It's a great way of separating the view logic from state and actions.
import { useState, useEffect } from "https://cdn.skypack.dev/preact/hooks";
import { render, html } from "https://cdn.skypack.dev/htm/preact";
function Title(props) {
const { title } = props;
return html` <h1>${title}</h1> `;
}
// The custom hook
function useCounter(initialValue) {
const [count, setCount] = useState(Number(initialValue));
const increaseCount = () => setCount(count + 1);
const decreaseCount = () => setCount(count - 1);
useEffect(() => {
console.log(`Count changed to ${count}`);
}, [count]);
return { increaseCount, decreaseCount, count };
}
function Counter(props) {
const { initialValue } = props;
// Notice that the hooks from previous code snippet has been moved into the `userCounter` hook.
const { increaseCount, decreaseCount, count } = useCounter(initialValue);
return html`
<${Title} title="Counter" />
<div>${count}</div>
<button onclick=${increaseCount}>Increase</button>
<button onclick=${decreaseCount}>Decrease</button>
`;
}
const appElements = document.querySelectorAll(".counter");
appElements.forEach((appElement) => {
render(
html`<${Counter} initialValue=${appElement.dataset.initialValue} />`,
appElement
);
});
Full code
As you can see, very little code and setup required to get interactive widgets inside your MVC framework, or PHP app if you prefer that:
Benefits
- Develop and ship without any Node.js bundling
- Much smaller footprint than React
- Performant
- Cached, shared runtime via Skypack
- You can place small interactive components everwhere without a transpilation step
- Syntax highlighting with lit-html on VSCode or vim-jsx-pretty for vim.
Drawbacks
- Might not work with every React library, but you can as mentioned use
React + htm
instead (andpreact/compat
might be possible) - You are not getting type-safety on the level Elm or PureScript, but you can use TypeScript annotations with JSDoc.
- You depend on Skypack, but should it break, you can also use unpkg with the
?module
parameter, or self-host.
What if I prefer to use React?
I guess the hero of this story is the htm
library. It's also a great match for React:
import ReactDOM from "https://cdn.skypack.dev/react-dom";
import { html } from "https://cdn.skypack.dev/htm/react";
function App() {
return html`<a href="/">Hello!</a>`;
}
ReactDOM.render(html`<${App} />`, document.getElementById("counter"));
This gives you access React libraries, and you can import them from Skypack!
I couldn't figure out how to combine htm
with preact/compat
, a Preact drop-in replacement for React. Please reach out to me if you figure it out 🙂
My conclusion
Preact modules are practical for quick prototyping of interactivity in a framework like IHP.
Ultimately, I want to have trustable code that won't break. I will continue using Elm, especially when a module becomes large or when the codebase matures in general.
Preact + htm
can be great for the extra initial speed when building a minimum viable product.