hyperapp/docs/tutorial.md

52 KiB
Raw Permalink Blame History

Tutorial

If you're new to Hyperapp, this is a great place to start. We'll cover all the essentials and then some, as we incrementally build up a simplistic example. To begin, open up an editor and type in this html:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./hyperapp-tutorial.css" />
    <script type="module">

/* Your code goes here */      

    </script>
  </head>
  <body>
    <main id="app"/>
  </body>
</html>

Save it as hyperapp-tutorial.html on your local drive, and in the same folder create the hyperapp-tutorial.css with the following css:

(expand tutorial css)
@import url('https://cdn.jsdelivr.net/npm/water.css@2/out/light.css');

:root {
  --box-width: 200px;
}

main { position: relative;}

.person {
  box-sizing: border-box;
  width: var(--box-width);
  padding: 10px 10px 10px 40px;
  position: relative;
  border: 1px #ddd solid;
  border-radius: 5px;  
  margin-bottom: 10px;
  cursor: pointer;
}

.person.highlight {
  background-color: #fd9;
}
.person.selected {
  border-width: 3px;
  border-color: #55c;
  padding-top: 8px;
  padding-bottom: 8px;
}

.person input[type=checkbox] {
  position: absolute;
  cursor: default;
  top: 10px;
  left: 7px;
}
.person.selected input[type=checkbox] {
  left: 5px;
  top: 8px;
}

.person p {
  margin: 0;
  margin-left: 2px;
}
.person.selected p {
  margin-left: 0;
} 

.bio {
  position: absolute;
  left: calc(var(--box-width) + 2rem);
  top: 60px;
  color: #55c;
  font-style: italic;
  font-size: 2rem;
  text-indent: -1rem;
}
.bio:before {content: '"';}
.bio:after {content: '"';}

Keep the html file open in a browser as you follow along the tutorial, to watch your progress. At each step there will be a link to a live-demo sandbox yo may refer to in case something isn't working right. (You could also use the sandbox to follow the tutorial if you prefer)

Hello world

Enter the following code:

import {h, text, app} from "https://cdn.skypack.dev/hyperapp"

app({
  view: () => h("main", {}, [
    h("div", {class: "person"}, [
      h("p", {}, text("Hello world")),
    ]),
  ]),
  node: document.getElementById("app"),
})

Save the file and reload the browser. You'll be greeted by the words "Hello world" framed in a box.

Live Demo

Let's walk through what happened:

You start by importing the three functions h, text and app.

You call app with an object that holds app's definition.

The view function returns a virtual DOM a blueprint of how we want the actual DOM to look, made up of virtual nodes. h creates virtual nodes representing HTML tags, while text creates representations of text nodes. The equivalent description in plain HTML would be:

<main>
  <div class="person">
    <p>Hello world</p>
  </div>
</main>

node declares where on the page we want Hyperapp to render our app. Hyperapp replaces this node with the DOM-nodes it generates from the description in the view.

State, View, Action

Initializing State

Add an init property to the app:

app({
  init: { name: "Leanne Graham", highlight: true },
  ...
})

Each app has an internal value called state. The init property sets the state's initial value. The view is always called with the current state, allowing us to display values from the state in the view.

Change the view to:

state => h("main", {}, [
  h("div", {class: "person"}, [
    h("p", {}, text(state.name)),
    h("input", {type: "checkbox", checked: state.highlight}),
  ]),
])

Save and reload. Rather than the statically declared "Hello world" from before, we are now using the name "Leanne Graham" from the state. We also added a checkbox, whose checked state depends on highlight.

Live Demo

Class properties

Change the definition of the div:

h("div", {class: {person: true, highlight: state.highlight}}, [ ... ])

The class property can be a string of space-separated class-names just like in regular HTML, or it can be an object where the keys are class-names. When the corresponding value is truthy, the class will be assigned to the element.

The highlight property of the state now controls both wether the div has the class "highlight" and wether the checkbox is checked.

Live Demo

However, clicking the checkbox to toggle the highlightedness of the box doesn't work. In the next step we will connect user interactions with transforming the state.

Actions

Define the function:

const ToggleHighlight = state => ({ ...state, highlight: !state.highlight })

It describes a transformation of the state. It expects a value in the shape of the app's state as argument, and will return something of the same shape. Such functions are known as actions. This particular action keeps all of the state the same, except for highlight, which should be flipped to its opposite.

Next, assign the function to the onclick property of the checkbox:

h("input", {
  type: "checkbox",
  checked: state.highlight,
  onclick: ToggleHighlight,
})

Save and reload. Now, clicking the checkbox toggles not only checked-ness but the higlighting of the box.

Live Demo

Dispatching

By assigning ToggleHighlight to onclick of the checkbox, we tell Hyperapp to dispatch ToggleHighlight when the click-event occurs on the checkbox. Dispatching an action means Hyperapp will use the action to transform the state. With the new state, Hyperapp will calculate a new view and update the DOM to match.

View components

Since the view is made up of nested function-calls, it is easy to break out a portion of it as a separate function for reuse & repetition.

Define the function:

const person = props =>
  h("div", {
    class: {
      person: true,
      highlight: props.highlight
    }
  }, [
    h("p", {}, text(props.name)),
    h("input", {
      type: "checkbox",
      checked: props.highlight,
      onclick: props.ontoggle,
    }),
  ])

Now the view can be simplified to:

state => h("main", {}, [
  person({
    name: state.name,
    highlight: state.highlight,
    ontoggle: ToggleHighlight,
  }),
])

Here, person is known as a view component. Defining and combining view components is a common practice for managing large views. Note, however, that it does not rely on any special Hyperapp-features just plain function composition.

Live Demo

Action payloads

This makes it easier to have multiple boxes in the view. First add more names and highlight values to the initial state, by changing init:

{
  names: [
    "Leanne Graham",
    "Ervin Howell",
    "Clementine Bauch",
    "Patricia Lebsack",
    "Chelsey Dietrich",
  ],
  highlight: [
    false,
    true,
    false,
    false,
    false,
  ],
}

next, update the view to map over the names and render a person for each one:

state => h("main", {}, [
  ...state.names.map((name, index) => person({
    name,
    highlight: state.highlight[index],
    ontoggle: [ToggleHighlight, index],
  })),
])

Notice how instead of assigning just ToggleHighlight to ontoggle, we assign [ToggleHighlight, index]. This makes Hyperapp dispatch ToggleHighlight with index as the payload. The payload becomes the second argument to the action.

Update ToggleHighlight to handle the new shape of the state, and use the index payload:

const ToggleHighlight = (state, index) => {
  // make shallow clone of original highlight array
  let highlight = [...state.highlight]

  // flip the highlight value of index in the copy
  highlight[index] = !highlight[index]
  
  // return shallow copy of our state, replacing 
  // the highlight array with our new one
  return { ...state, highlight}
}

Save & reload. You now have five persons. Each can be individually highlighted by toggling its checkbox.

Live Demo

Next, let's add the ability to "select" one person at a time by clicking on it. We only need what we've learned so far to achieve this.

First, add a selected property to init, where we will keep track of the selected person by its index. Since no box is selected at first, selected starts out as null:

{
  ...
  selected: null, 
}

Next, define an action for selecting a person:

const Select = (state, selected) => ({...state, selected})

Next, pass the selected property, and Select action to the person component:

person({
  name,
  highlight: state.highlight[index],
  ontoggle: [ToggleHighlight, index],
  selected: state.selected === index, // <----
  onselect: [Select, index],          // <----
})

Finally, we give the selected person the "selected" class to visualize wether it is selected. We also pass the given onselect property on to the onclick event handler of the div.

const person = props =>
  h("div", {
    class: {
      person: true,
      highlight: props.highlight,
      selected: props.selected,    // <---
    },
    onclick: props.onselect,       // <---
  }, [
    h("p", {}, text(props.name)),
    h("input", {
      type: "checkbox",
      checked: props.highlight,
      onclick: props.ontoggle,
    }),
  ])

Save, reload & try it out by clicking on the different persons.

Live Demo

DOM-event objects

But now, when we toggle a checkbox, the person also selected. This happens because the onclick event bubbles up from the checkbox to the surrounding div. That is just how the DOM works. If we had access to the event object we could call the stopPropagation method on it, to prevent it from bubbling. That would allow toggling checkboxes without selecting persons.

As it happens, we do have access to the event object! Bare actions (not given as [action, payload]) have a default payload which is the event object. That means we can define the onclick action of the checkbox as:

onclick: (state, event) => {
  event.stopPropagation()
  //...
}

But what do we do with the props.ontoggle that used to be there? We return it!

h("input", {
  type: "checkbox",
  checked: props.highlight,
  onclick: (_, event) => {
    event.stopPropagation()
    return props.ontoggle
  },
})

When an action returns another action, or an [action, payload] tuple instead of a new state, Hyperapp will dispatch that instead. You could say we defined an "intermediate action" just to stop the event propagation, before continuing to dispatch the action originally intended.

Save, reload and try it! You should now be able to highlight and select persons independently.

Live Demo

Conditional rendering

Further down we will be fetching the "bio" of selected persons from a server. For now, let's prepare the app to receive and display the bio.

Begin by adding an initially empty bio property to the state, in init:

{
  ...,
  selected: null,
  bio: "",       // <---
}

Next, define an action that saves the bio in the state, given some server data:

const GotBio = (state, data) => ({...state, bio: data.company.bs})

And then add a div for displaying the bio in the view:

state => h("main", {}, [
  ...state.names.map((name, index) => person({
    name,
    highlight: state.highlight[index],
    ontoggle: [ToggleHighlight, index],
    selected: state.selected === index,
    onselect: [Select, index],
  })),
  state.bio &&                                  // <---
  h("div", { class: "bio" }, text(state.bio)),  // <---
])

The bio-div will only be shown if state.bio is truthy. You may try it for yourself by setting bio to some nonempty string in init.

Live Demo

This technique of switching parts of the view on or off using && (or switching between different parts using ternary operators A ? B : C) is known as conditional rendering

Effects

Effecters

In order to fetch the bio, we will need the id associated with each person. Add the ids to the initial state for now:

{
  ...
  selected: null,
  bio: "",
  ids: [1, 2, 3, 4, 5], // <---
}

We want to perform the fetch when a person is selected, so update the Select action:

const Select = (state, selected) => {

  fetch("https://jsonplaceholder.typicode.com/users/" + state.ids[selected])
  .then(response => response.json())
  .then(data => {
    console.log("Got data: ", data)
  
    /* now what ? */
  })

  return {...state, selected}
}

We will be using the JSONPlaceholder service in this tutorial. It is a free & open source REST API for testing & demoing client-side api integrations. Be aware that some endpoints could be down or misbehaving on occasion.

If you try that, you'll see it "works" in the sense that data gets fetched and logged but we can't get it from there in to the state!

Hyperapp actions are not designed to be used this way. Actions are not general purpose event-handlers for running arbitrary code. Actions are meant to simply calculate a value and return it.

The way to run arbitrary code with some action, is to wrap that code in a function and return it alongside the new state:

const Select = (state, selected) => [
  {...state, selected},
  () => 
    fetch("https://jsonplaceholder.typicode.com/users/" + state.ids[selected])
    .then(response => response.json())
    .then(data => {
      console.log("Got data: ", data)
      /* now what ? */
    })
]

When an action returns something like [newState, [function]], the function is known as an effecter (a k a "effect runner"). Hyperapp will call that function for you, as a part of the dispatch process. What's more, Hyperapp provides a dispatch function as the first argument to effecters, allowing them to "call back" with response data:

const Select = (state, selected) => [
  {...state, selected},
  dispatch => {                           // <---
    fetch("https://jsonplaceholder.typicode.com/users/" + state.ids[selected])
    .then(response => response.json())
    .then(data => dispatch(GotBio, data)) // <---
  }
]

Now when a person is clicked, besides showing it as selected, a request for the persons's data will go out. When the response comes back, the GotBio action will be dispatched, with the response data as payload. This will set the bio in the state and the view will be updated to display it.

Live Demo

Effects

There will be other things we want to fetch in a similar way. The only difference will be the url and action. So let's define a reusable version of the effecter where url and action are given as an argument:

const fetchJson = (dispatch, options) => {
  fetch(options.url)
  .then(response => response.json())
  .then(data => dispatch(options.action, data))
}

Now change Select again:

const Select = (state, selected) => [
  {...state, selected},
  [ 
    fetchJson,
    {
      url: "https://jsonplaceholder.typicode.com/posts/" + state.ids[selected],
      action: GotBio,
    }
  ]
]

A tuple such as [effecter, options] is known as an effect. The options in the effect will be provided to the effecter as the second argument. Everything works the same as before, but now we can reuse fetchJson for other fetching we may need later.

Live Demo

Effect creators

Define another function:

const jsonFetcher = (url, action) => [fetchJson, {url, action}]

It allows us to simplify Select even more:

const Select = (state, selected) => [
  {...state, selected},
  jsonFetcher("https://jsonplaceholder.typicode.com/users/" + state.ids[selected], GotBio),
]

Here, jsonFetcher is what is known as an effect creator. It doesn't rely any special Hyperapp features. It is just a common way to make using effects more convenient and readable.

Live Demo

Effects on Init

The init property works as if it was the return value of an initially dispatched action. That means you may set it as [initialState, someEffect] to have the an effect run immediately on start.

Change init to:

[
  {names: [], highlight: [], selected: null, bio: "", ids: []},
  jsonFetcher("https://jsonplaceholder.typicode.com/posts", GotNames)
]

This means we will not have any names or ids for the persons at first, but will fetch this information from a server. The GotNames action will be dispatched with the response, so implement it:

const GotNames = (state, data) => ({
  ...state,
  names: data.slice(0, 5).map(x => x.name),
  ids: data.slice(0, 5).map(x => x.id),
  highlight: [false, false, false, false, false],
})

With that, you'll notice the app will now get the names from the API instead of having them hardcoded.

Live Demo

Subscriptions

Our final feature will be to make it possible to move the selection up or down using arrow-keys. First, define the actions we will use to move the selection:

const SelectUp = state => {
  if (state.selected === null) return state
  return [Select, state.selected - 1]
}

const SelectDown = state => {
  if (state.selected === null) return state
  return [Select, state.selected + 1]
}

When we have no selection it makes no sense to "move" it, so in those cases both actions simply return state which is effectively a no-op.

You may recall from earlier, that when an action returns [otherAction, somePayload] then that other action will be dispatched with the given payload. We use that here in order to piggy-back on the fetch effect already defined in Select.

Now that we have those actions how do we get them dispatched in response to keydown events?

If effects are how an app affects the outside world, then subscriptions are how an app reacts to the outside world. In order to subscribe to keydown events, we need to define a subscriber. A subscriber is a lot like an effecter, but wheras an effecter contains what we want to do, a subscriber says how to start listening to an event. Also, subscribers must return a function that lets Hyperapp know how to stop listening:

const mySubscriber = (dispatch, options) => {
  /* how to start listening to something */
  return () => {
    /* how to stop listening to the same thing */
  }
}

Define this subscriber that listens to keydown events. If the event key matches options.key we will dispatch options.action.

const keydownSubscriber = (dispatch, options) => {
  const handler = ev => {
    if (ev.key !== options.key) return
    dispatch(options.action)
  }
  addEventListener("keydown", handler)
  return () => removeEventListener("keydown", handler)
}

Now, just like effects, let's define a subscription creator for convenient usage:

const onKeyDown = (key, action) => [keydownSubscriber, {key, action}]

A pair of [subscriber, options] is known as a subscription. We tell Hyperapp what subscriptions we would like active through the subscriptions property of the app definition. Add it to the app call:

app({
  ...,
  subscriptions: state => [
    onKeyDown("ArrowUp", SelectUp),
    onKeyDown("ArrowDown", SelectDown),
  ]
})

This will start the subscriptions and keep them alive for as long as the app is running.

But we don't actually want these subscriptions running all the time. We don't want the arrow-down subscription active when the bottom person is selected. Likewise we don't want the arrow-up subscription action when the topmost person is selected. And when there is no selection, neither subscription should be active. We can tell Hyperapp this using logic operators just as how we do conditional rendering:

app({
  ...,
  subscriptions: state => [

    state.selected !== null &&
    state.selected > 0 &&
    onKeyDown("ArrowUp", SelectUp),

    state.selected !== null &&
    state.selected < (state.ids.length - 1) &&
    onKeyDown("ArrowDown", SelectDown),
  ],
})

Each time the state changes, Hyperapp will use the subscriptions function to see which subscriptions should be active, and start/stop them accordingly.

Live Demo

Conclusion

That marks the completion of this tutorial. Well done! We've covered state, actions, views, effects and subscriptions and really there isn't much more to it. You are ready to strike out on your own and build something amazing!