HyperApp: The 1 KB JavaScript Library for Building Front-End Apps

Jorge Bucaran
Share

Hyperapp is a JavaScript library for building feature-rich web applications. It combines a pragmatic Elm-inspired approach to state management with a VDOM engine that supports keyed updates & lifecycle events — all without dependencies. Give or take a few bytes, the entire source code minified and gzipped sits at around 1 KB.

In this tutorial, I’ll introduce you to Hyperapp and walk you through a few code examples to help you get started right away. I’ll assume some familiarity with HTML and JavaScript, but previous experience with other frameworks is not required.

Hello World

We’ll start with a simple demo that shows all the moving parts working together.

You can try the code online too.

import { h, app } from "hyperapp"
// @jsx h

const state = {
  count: 0
}

const actions = {
  down: () => state => ({ count: state.count - 1 }),
  up: () => state => ({ count: state.count + 1 })
}

const view = (state, actions) => (
  <div>
    <h1>{state.count}</h1>
    <button onclick={actions.down}>-</button>
    <button onclick={actions.up}>+</button>
  </div>
)

app(state, actions, view, document.body)

This is more or less how every Hyperapp application looks like. A single state object, actions that populate the state and a view that translates state and actions into a user interface.

Inside the app function, we make a copy of your state and actions (it would be impolite to mutate objects we don’t own) and pass them to the view. We also wrap your actions so they re-render the application every time the state changes.

app(state, actions, view, document.body)

The state is a plain JavaScript object that describes your application data model. It’s also immutable. To change it you need to define actions and call them.

const state = {
  count: 0
}

Inside the view, you can display properties of the state, use it to determine what parts your UI should be shown or hidden, etc.

<h1>{state.count}</h1>

You can also attach actions to DOM events, or call actions within your own inlined event handlers.

<button onclick={actions.down}>-</button>
<button onclick={actions.up}>+</button>

Actions don’t mutate the state directly but return a new fragment of the state. If you try to mutate the state inside an action and then return it, the view will not be re-rendered as you might expect.

const actions = {
  down: () => state => ({ count: state.count - 1 }),
  up: () => state => ({ count: state.count + 1 })
}

The app call returns the actions object wired to the state-update view-render cycle. You also receive this object inside the view function and within actions. Exposing this object to the outside world is useful because it allows you to talk to your application from another program, framework or vanilla JavaScript.

const main = app(state, actions, view, document.body)

setTimeout(main.up, 1000)

A note about JSX

I’ll be using JSX throughout the rest of this document for familiarity, but you are not required to use JSX with Hyperapp. Alternatives include the built-in h function, @hyperapp/html, hyperx and t7.

Here is the same example from above using @hyperapp/html.

import { app } from "hyperapp"
import { div, h1, button } from "@hyperapp/html"

const state = { count: 0 }

const actions = {
  down: () => state => ({ count: state.count - 1 }),
  up: () => state => ({ count: state.count + 1 })
}

const view = (state, actions) =>
  div([
    h1(state.count),
    button({ onclick: actions.down }, "–"),
    button({ onclick: actions.up }, "+")
  ])

app(state, actions, view, document.body)

Virtual DOM

A virtual DOM is a description of what a DOM should look like, using a tree of nested JavaScript objects known as virtual nodes.

{
  name: "div",
  props: {
    id: "app"
  },
  children: [{
    name: "h1",
    props: null,
    children: ["Hi."]
  }]
}

The virtual DOM tree of your application is created from scratch on every render cycle. This means we call the view function every time the state changes and use the newly computed tree to update the actual DOM.

We try to do it in as few DOM operations as possible, by comparing the new virtual DOM against the previous one. This leads to high efficiency, since typically only a small percentage of nodes need to change, and changing real DOM nodes is costly compared to recalculating a virtual DOM.

To help you create virtual nodes in a more compact way, Hyperapp provides the h function.

import { h } from "hyperapp"

const node = h(
  "div",
  {
    id: "app"
  },
  [h("h1", null, "Hi.")]
)

Another way to create virtual nodes is with JSX. JSX is a JavaScript language extension used to represent dynamic HTML.

import { h } from "hyperapp"

const node = (
  <div id="app">
    <h1>Hi.</h1>
  </div>
)

Browsers don’t understand JSX, so we need to compile it into h function calls, hence the import h statement. Let’s see how this process works using babel.

First, install dependencies:

npm i babel-cli babel-plugin-transform-react-jsx

Then create a .babelrc file:

{
  "plugins": [
    [
      "transform-react-jsx",
      {
        "pragma": "h"
      }
    ]
  ]
}

And compile the code from the command line:

npm run babel src/index.js > index.js

If you prefer not to use a build system, you can also load Hyperapp from a CDN like unpkg and it will be globally available through the window.hyperapp object.

Examples

Gif Search Box

In this example, I’ll show you how to update the state asynchronously using the Giphy API to build a Gif search box

To produce side effects we call actions inside other actions, within a callback or when a promise is resolved.

Actions that return null, undefined or a Promise object don’t trigger a view re-render. If an action returns a promise, we’ll pass the promise to the caller allowing you to create async actions like in the following example.

Live Example

import { h, app } from "hyperapp"
// @jsx h

const GIPHY_API_KEY = "dc6zaTOxFJmzC"

const state = {
  url: "",
  query: "",
  isFetching: false
}

const actions = {
  downloadGif: query => async (state, actions) => {
    actions.toggleFetching(true)
    actions.setUrl(
      await fetch(
        `//api.giphy.com/v1/gifs/search?q=${query}&api_key=${GIPHY_API_KEY}`
      )
        .then(data => data.json())
        .then(({ data }) => (data[0] ? data[0].images.original.url : ""))
    )
    actions.toggleFetching(false)
  },
  setUrl: url => ({ url }),
  setQuery: query => ({ query }),
  toggleFetching: isFetching => ({ isFetching })
}

const view = (state, actions) => (
  <div>
    <input type="text"
      placeholder="Type here..."
      autofocus
      onkeyup={({ target: { value } }) =/> {
        if (value !== state.query) {
          actions.setQuery(value)
          if (!state.isFetching) {
            actions.downloadGif(value)
          }
        }
      }}
    />
    <div class="container">
      <img src={state.url}
        style={{
          display: state.isFetching || state.url === "" ? "none" : "block"
        }}
      />
    </div>
  </div>
)

app(state, actions, view, document.body)

The state stores a string for the Gif URL, the search query and a boolean flag to know when the browser is fetching a new Gif.

const state = {
  url: "",
  query: "",
  isFetching: false
}

The isFetching flag is used to hide the Gif while the browser is busy. Without it, the last downloaded Gif would be shown as another one is requested.

<img src={state.url}
  style={{
    display: state.isFetching || state.url === "" ? "none" : "block"
  }}
/>

The view consists of a text input and an img element to display the Gif.

To handle user input, the onkeyup event is used, but onkeydown or oninput would work as well.

On every keystroke actions.downloadGif is called and a new Gif is requested, but only if a fetch is not already pending and the text input is not empty.

if (value !== state.query) {
  actions.setQuery(value)
  if (!state.isFetching) {
    actions.downloadGif(value)
  }
}

Inside actions.downloadGif we use the fetch API to request a Gif URL from Giphy.

When fetch is done, we receive the payload with the Gif information inside a promise.

actions.toggleFetching(true)
actions.setUrl(
  await fetch(
    `//api.giphy.com/v1/gifs/search?q=${query}&api_key=${GIPHY_API_KEY}`
  )
    .then(data => data.json())
    .then(({ data }) => (data[0] ? data[0].images.original.url : ""))
)
actions.toggleFetching(false)

Once the data has been received, actions.toggleFetching is called (which allows further fetch requests to be made) and the state is updated by passing the fetched Gif URL to actions.setUrl.

TweetBox Clone

In this example, I’ll show you how to create custom components to organize your UI into reusable markup and build a simple TweetBox clone.

Live Example

import { h, app } from "hyperapp"
// @jsx h

const MAX_LENGTH = 140
const OFFSET = 10

const OverflowWidget = ({ text, offset, count }) => (
  <div class="overflow">
    <h1>Whoops! Too long.</h1>
    <p>
      ...{text.slice(0, offset)}
      <span class="overflow-text">{text.slice(count)}</span>
    </p>
  </div>
)

const Tweetbox = ({ count, text, update }) => (
  <div>
    <div class="container">
      <ul class="flex-outer">
        <li>
          <textarea placeholder="What's up?" value={text} oninput={update}></textarea>
        </li>

        <li class="flex-inner">
          <span class={count > OFFSET ? "overflow-count" : "overflow-count-alert"}
          >
            {count}
          </span>

          <button onclick={() => alert(text)}
            disabled={count >= MAX_LENGTH || count < 0}
          >
            Tweet
          </button>
        </li>
      </ul>

      {count < 0 && (
        <OverflowWidget
          text={text.slice(count - OFFSET)}
          offset={OFFSET}
          count={count}
        />
      )}
    </div>
  </div>
)

const state = {
  text: "",
  count: MAX_LENGTH
}

const view = (state, actions) => (
  <tweetbox text={state.text}
    count={state.count}
    update={e => actions.update(e.target.value)}
  />
)

const actions = {
  update: text => state => ({
    text,
    count: state.count + state.text.length - text.length
  })
}

app(state, actions, view, document.body)

The state stores the text of the message and the number of remaining characters count, initialized to MAX_LENGTH.

const state = {
  text: "",
  count: MAX_LENGTH
}

The view consists of our TweetBox component. We use the attributes/props, to pass down data into the widget.

const view = (state, actions) => (
  </tweetbox><tweetbox text={state.text}
    count={state.count}
    update={e => actions.update(e.target.value)}
  />
)

When the user types in the input, we call actions.update() to update the current text and calculate the remaining characters.

update: text => state => ({
  text,
  count: state.count + state.text.length - text.length
})

The subtracting the length of the current text from the length of the previous text tells us how the number of remaining characters has changed. Hence the new count of remaining characters is the old count plus the aforementioned difference.

When the input is empty, this operation is equal to (MAX_LENGTH - text.length).

When state.count becomes less than 0, we know that state.text must be longer than MAX_LENGTH, so we can disable the tweet button and display the OverflowWidget component.

<button onclick={() => alert(text)} disabled={count >= MAX_LENGTH || count < 0}>
  Tweet
</button>

The tweet button is also disabled when state.count === MAX_LENGTH, because that means we have not entered any characters.

The OverflowWidget tag displays the unallowed part of the message and a few adjacent characters for context. The constant OFFSET tells us how many extra characters to slice off state.text.

<overflowwidget text={text.slice(count - OFFSET)}
  offset={OFFSET}
  count={count}></overflowwidget>

By passing OFFSET into OverflowWidget we are able to slice text further and apply an overflow-text class to the specific overflowed part.

<span class="overflow-text">{text.slice(count)}</span>

Comparison with React

At a conceptual level, Hyperapp and React have a lot in common. Both libraries use a virtual DOM, lifecycle events, and key-based reconciliation. Hyperapp looks and feels a lot like React and Redux, but with less boilerplate.

React popularized the idea of a view as a function of the state. Hyperapp takes this idea a step further with a built-in, Elm-inspired state management solution.

Hyperapp rejects the idea of local component state relying only on pure functional components. This translates to high reusability, cheap memoization, and simple testing.

Final Thoughts

Because Hyperapp is so tiny, it is faster to transfer over the network and faster to parse than virtually any alternative out there. This means fewer concepts to learn, fewer bugs and more framework stability.

I’ve never been a fan of big frameworks. Not because they aren’t great, but because I want to write my own JavaScript, not the JavaScript a framework wants me to use. The meat of it is I want transferable skills. I want to grow skills in JavaScript, not skills into frameworks.


To learn more about Hyperapp check out the official documentation and follow us on Twitter for updates and announcements.