Portrait picture
Zacharias Enochsson
Web Developer
contact

Astro Hyperapp Integration

Hyperapp Logo ❤️ Astro Logo

Astro islands using hyperapp. Read on or check out the demo. To get started, install it and import in your astro.config.mjs


> npm install @zxlabs/astrojs-hyperapp    
    
// astro.config.mjs

import { defineConfig } from "astro/config"
import hyperapp from "@zxlabs/astrojs-hyperapp"

export default defineConfig({
  integrations: [hyperapp()],
})  
    

If you're using jsx for your components, and also use other frameworks that use jsx, you need to explicitly tell the integrations which files it should include or exclude using picomatch patterns.

This might require you to use some naming convention or folder structure to make it possible to separate components from different frameworks.

// astro.config.mjs

import { defineConfig } from "astro/config"
import hyperapp from "@zxlabs/astrojs-hyperapp"
import preact from "@astrojs/preact"

export default defineConfig({
  integrations: [
    hyperapp({ include: ["**/hyperapp/*"] }), //exclude option also possible
    preact({ include: ["**/preact/*"] }), //exclude option also possible
  ],
})
    

Authoring islands

In astrojs, an 'island' is a part of a page that is hooked up to a client-side framework for interactivity. Each of our islands will be activated (or "hydrated") with a call to Hyperapp's app(...) function.

An island module needs to default-export a function that optionally takes some server-side props, and returns an object we could pass to Hyperapp's app() – except the node prop, which will be provided by this integration.

Using islands on a page

With a island thus defined, the we can use hyperapp to render the component (both server side and hydrate client side), in our astro-components like this:

Note how the props passed in to the island in the astro-component, are available in the serverProps we defined in the counter.

// components/counter.jsx

const incr = x => x + 1
const decr = x => x - 1

export default const serverProps => ({

  init: serverProps.startCount || 0,

  view: value => (
    <div class="counter">
      <h1>{value}</h1>
      <button onclick={decr}> - </button>
      <button onclick={incr}> + </button>
    </div>
  ),

  // could also define these props:
  // subscriptions: ...
  // dispatch: ...
})
    

  ---
  // pages/index.astro

  import Counter from '../components/counter.jsx'
  ---
  <!doctype html>
  <html>
    <head></head>
    <body>
      <p> Here is the counter, starting at 5</p>
  
      <Counter client:load startCount={5} />
  
    </body>
  </html>    
    

Here is the counter, starting at 5

5

Passing static content to islands

You can pass static content from astro components in to your islands, for example to control hiding/showing the content.


---

import ContentToggle from '../components/ContentToggle.jsx'

---
<p> Click to reveal contents:</p>
<ContentToggle client:load>
  <p style="color: #ffaa33">
    This orange text is hidden by default.
  </p>  
</ContentToggle>

  

Click to reveal contents:

This is the default behavior for all content not specifically slotted. You can also set a html node to appear in a specific slot with the slot attribute.


---

import ContentToggle from '../components/ContentToggle.jsx'

---
<ContentToggle client:load>
  <p style="color: #33ffff">
    This turquoise text is hidden by default.
  </p>  
  <p slot="footer">
    This text is always visible  
  </p>
</ContentToggle>

  

Click to reveal contents:

This text is always visible

Content for the default slot is passed to the island as a second argument. Named slots are passed to the component as properties:

Slot-names need to be given as snake/kebab case (e.g slot="kebab-case" or slot="snake_case") in .astro files (in order to be html-compliant). But for your convenience, such names are transformed to camelCase (e.g. props.kebabCase or props.snakeCase) in the props passed to the island.


// components/ContentToggle.jsx

const toggle = showing => !showing

export default (props, content) => ({

   init: false,

   view: showing => (
     <div style={{ outline: "1px #ffffff33 solid" }}>
       <button onclick={toggle}>
         {showing ? "Hide" : "Show"}
       </button>
       {showing && content}
       {props.footer}
     </div>
   ),

 })

Sharing state between islands

Since each island will be it's own instance of a hyperapp-app they will not share state. Astro recommends using nanostores for sharing states, and that is a perfectly good option. You will just have to write your own effects/subscriptions.

Another option is to use state synchronization utility shipped with this integration. With this, you define a headless "master-app" by providing an init prop and optionally subscriptions and dispatch (for middleware).


// components/master.js

import syncedIslands from "@zxlabs/astrojs-hyperapp/synced-islands"
  
export default syncedIslands({
  init: { name: "Person McNameson", fruit: "apples" },
  //subscriptions: ...
  //dispatch: ...
})  
  

It will return a function which islands can use to provide just their views in a convenient way. Although they will be rendered as separate islands (and technically individual app-instances), they will share the same state, and rerender whenever it is updated.


// components/island1.js

import master from "./master.js"
  
const setFruit = (state, ev) => ({
  ...state,
  fruit: ev.target.value,
})

export default () =>
  master(state => (
    <div class="synced-demo">
      <p>
        Hello <span class="data">{state.name}</span>!
        What's your favorite fruit? &nbsp;
        <select onchange={setFruit}>
          <option selected={state.fruit === "apples"}>apples</option>
          <option selected={state.fruit === "oranges"}>oranges</option>
          <option selected={state.fruit === "bananas"}>bananas</option>
        </select>
      </p>
    </div>
  ))
  

Hello Person McNameson! What's your favorite fruit?  


// components/island2.js

import master from "./master.js"
    
const setName = (state, ev) => ({
  ...state,
  name: ev.target.value,
})

export default () =>
  master(state => (
    <div class="synced-demo">
      <p>
        I hear you like <span class="data">{state.fruit}</span>!
        What's your name? &nbsp;
        <input type="text" value={state.name} oninput={setName} />
      </p>
    </div>
  ))    
    

I hear you like apples! What's your name?