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)
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.
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.
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
.
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.
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.
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.
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.
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.
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.
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.
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.
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
.
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
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.
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.
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.
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.
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.
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!