Demo
Since this site is built with Astro, here is a demonstration of the counter example working
❤️
Static/server rendered Hyperapp components with Astro. JSX/TSX-support, Hot-Module-Reloading, Server-Side-Rendering & Synchronized Components
Astro is a static-site-generator – it lets you generate a website as static files from various content-sources. It can also run as a server, rendering your html/css at runtime. Unlike similar tools, Astro is agnostic about the client side, allowing any frontend framework (or none!) by way of integrations.
Hyperapp is an extremely tiny, fast and simple client-side UI framework. With this integration, you get to use Hyperapp in your Astro site, along with:
synchronized-islands
utility provided by this integration.Have a look at a demo at https://codesandbox.io/p/github/zaceno/astrojs-hyperapp-demo, or read on to try for yourself
If you don’t already have an astro project, set one up following the instructions at: https://docs.astro.build/en/install-and-setup/
astrojs-hyperapp
Open a shell and change directory to the root of your astro project, then run:
> npm install hyperapp astrojs-hyperapp
Enable the integration by including it in your astro.config.mjs
file:
import { defineConfig } from "astro/config"
import hyperapp from "astrojs-hyperapp"
export default defineConfig({
integrations: [hyperapp()],
})
In Astro, an “island” is a mount-point for a client-side component is rendered.
Make a new folder called client
inside src/
to keep your client-side code separate from the server-side code. In that folder, create a file Counter.jsx
:
const increment = state => ({ ...state, count: state.count + 1 })
const decrement = state => ({ ...state, count: state.count - 1 })
export default () => ({
init: { count: 0 },
view: state => (
<div>
<h1>{state.count}</h1>
<button onclick={decrement}>-</button>
<button onclick={increment}>+</button>
</div>
),
})
Open up src/pages/index.astro
and
---
import Counter from '../client/Counter' //<-- add this
---
<html lang="en">
<head>
...
<title>Astro</title>
</head>
<body>
<h1>Astro</h1>
<!-- and add this: -->
<p>A hyperapp counter:</p>
<Counter client:load />
<!-- // -->
</body>
</html>
“client:load” tells astro to hydrate the component as soon as the page is loaded. See https://docs.astro.build/en/reference/directives-reference/#client-directives for more info
Start Astro’s dev server by opening your shell, changing directory to your Astro project, and running:
> npm run dev
Then open up a browser to http://localhost:4321/
. You should see your standard Astro index page, and in it you should see your counter-component rendered, and working (click the buttons to try it out!)
Since this site is built with Astro, here is a demonstration of the counter example working
Hyperapp islands are functions that return objects with the same properties you would pass to Hyperapp’s app({...})
call (init
, view
, subscriptions
, dispatch
) – all except the node
property (which astro provides for us). Upon hydration, a hyperapp-instance is started on the island mount-point. Therefore a view
is required, and you probably want an init
, but subscriptions
and dispatch
are optional.
An Astro-page can send props to your island, in the first argument to your island function. For example:
//some-page.astro
<Counter client:load startCount={5} />
will send {startCount: 5}
as the first argument to your island function, so you can handle adapt this particular instance to the given props:
//Counter.jsx
export default (serverProps) => ({
init: {count: serverProps.startCount},
view: state => ...
})
You can pass static html content to you islands, from Astro pages:
---
import ContentToggle from '../components/ContentToggle.jsx'
---
<p> Click to reveal contents:</p>
<ContentToggle client:load>
<p class="red">
This text his hidden by default. And red.
</p>
</ContentToggle>
<style>
.red {
color: red;
}
</style>
This static content is provided to the island as a Hyperapp virtual-node in the second argument:
export default (_, content) => ({
init: false,
view: showing => (
<div>
<p>
<button onclick={showing => !showing}>
{showing ? 'Hide' : 'Show'}
</button>
</p>
{showing && content}
</div>
)
})
This is what slotting content into a Hyperapp island looks like
This text is hidden by default. And red.
Sometimes you might want to pass more than one set of static html to an island. You can do that by setting the slot
attribute to a name you choose, on an element in the static content being passed in, that you want to distinguish from the rest:
---
import ContentToggle from '../components/ContentToggle.jsx'
---
<p> Click to reveal contents:</p>
<ContentToggle client:load>
<p class="red">
This text his hidden by default. And red.
</p>
<p slot="footer">
This footer text is always visible
</p>
</ContentToggle>
<style>
.red {
color: red;
}
</style>
Slotted content is not included in the second content
argument to your island, but is instead passed as a prop in the first argument, with the same name you gave the slot.
//ContentToggle.jsx
export default (props, content) => ({
init: false,
view: showing => (
<div style={{border: '1px black solid'}}>
<button onclick={showing => !showing}>
{showing ? 'Hide' : 'Show'}
</button>
<div>
{showing && content}
{props.footer}
</div>
)
})
This demonstrates the example above of passing multiple slots to a Hyperapp islands
This footer text is always visible
This text his hidden by default. And red.
Slot-names need to be given as snake/kebab case (e.g
slot="kebab-case"
orslot="snake_case"
) in.astro
files, in order to be html-compliant. But for your convenience, they are transformed to camelCase (e.g.props.kebabCase
orprops.snakeCase
) in the props passed to the island.
Every island in an Astro page is … an island, unto itself. It has its own state and doesn’t know what’s going on in any other island unless you build in some signalling/sharing mechanism of your own. This is true for any choice of client-side framework.
Astro’s recommended solution to sharing state between islands is nanostores. It is a fine solution, but will (for now) require you to implement your own effects & subscriptions for interacting with nanostores.
As a convenient alternative, this integration offers a mechanism for you to define synchronized islands. Islands that share the same state.
Start by definining a synchronizer, where you pass in an init
prop, and optionally subscriptions
& dispatch
props. Again, these are the same as you would pass to a Hyperapp app({...})
call.
//chat-master.js
import makeSynchronizer from 'astrojs-hyperapp/synced-islands'
export const chatSynchronizer = makeSynchronizer({
init: ... // initial states and effects
subscriptions: ... // optional
dispatch: ... // optional
})
Now, for every island that you want to share this state, import the synchronizer, and use it to define your island by giving it only a view (the rest of the props are provided by the synchronizer):
//chat-messages.jsx
import { chatSynchronizer } from "./chat-master.js"
export default props =>
chatSynchronizer(state => (
<div class="chat-messages">{/* define chat-messages view here */}</div>
))
//chat-status.jsx
import { chatSynchronizer } from "./chat-master.js"
export default props =>
chatSynchronizer(state => (
<div class="chat-status">{/* define chat-status view here */}</div>
))
//chat-notifications.jsx
import { chatSynchronizer } from "./chat-master.js"
export default props =>
chatSynchronizer(state => (
<div class="chat-notifications">
{/* define chat-notifications view here */}
</div>
))
Each island will technically have it’s own hyperapp-instance running. But they are wired up so as to immediately synchronize their state between them. Therefore, they can also share and reuse the same actions.
Each of these three panels is a separate hyperapp-island, yet they share the same state
Complete inventory:
Count apples:
Set which product to count:
When you pass children to a component, like:
<MyComponent>
<p>foo</p>
<p>bar</p>
</MyComponent>
they will arrive as an array of virtual nodes in the second argument to the component.
const MyComponent = (props, children) => {
//children will be: [<p>foo</p>, <p>bar</p>]
}
It’s ok to put this array of children among other children, because the jsx transform will flatten the child-list:
const MyComponent = (props, children) => (
<div>
<h1>Header</h1>
{children} {/* <--- this is fine */}
</div>
)
In order to use TSX (type-aware JSX), you will need to add the following lines to your tsconfig.json
file under "compilerOptions": { ... }
:
"jsx": "preserve",
"jsxFactory": "jsx",
"jsxFragmentFactory": "jsxFragment"
You should also create a src/env.d.ts
file - a recommended place to put global type definitions for your code - and in it, add the line:
/// <reference types="astrojs-hyperapp/jsx" />
By default, this integration will affect every .js, .ts, .jsx and .tsx file in your project, which might cause undesired effects. You can limit what files this integration processes, by passing include
and/or exclude
options to the integration in astro.config.mjs
.
import { defineConfig } from "astro/config"
import hyperapp from "astrojs-hyperapp"
export default defineConfig({
integrations: [hyperapp({
include?: String | RegExp | Array[...String|RegExp],
exclude?: String | RegExp | Array[...String|RegExp]
})],
})
Where strings are taken as picomatch patterns.
If you want to use Hyperapp alongside another client-side UI-framework that also uses .jsx, like React, you will need to separate their integration scope using the include/exclude options mentioned above.
A recommended practice is to keep components for different frameworks in named folders, for example: src/client/hyperapp/
for everything using Hyperapp, and src/client/react/
for everything using React. Then astro.config.mjs
could look like this:
import { defineConfig } from "astro/config"
import hyperapp from "astrojs-hyperapp"
import react from "@astrojs/react"
export default defineConfig({
integrations: [
hyperapp({ include: "**/hyperapp/*" }),
react({ include: "**/react/*" }),
],
})
Also, since we need to set our own compilerOptions for jsx in tsconfig.json
, which breaks react’s jsx processing, you will need to add the following pragma at the top of all React components:
/** @jsxRuntime automatic */
/** @jsxImportSource react */
client:only
issueDue to a current limitation in astro, if you are using another client-side framework like React alongside Hyperapp, the client:only
directive is unusable for Hyperapp islands.
If you try, you will get an error about a missing renderer-hint. Even if you specify client:only="hyperapp"
. This is due to the fact that “hyperapp” is not listed in the internal, hard-coded list of ‘sanctioned’ renderers.