501 lines
32 KiB
HTML
501 lines
32 KiB
HTML
<h1 id="tutorial">tutorial</h1><p>===================================</p><p>Welcome! If you're new to Hyperapp, you've found the perfect place to start learning.</p><h2 id="the-set-up">The Set-up</h2><p>Together we'll build a simple newsreader-like application. As we do, we'll work
|
||
our way through the five core concepts: view, state, actions, effects and subscriptions.</p><p>To move things along, let's imagine we've already made a static version of the
|
||
app we want to build, with this HTML:</p><pre><code class="language-html"><div id="app" class="container">
|
||
<div class="filter">
|
||
Filter:
|
||
<span class="filter-word">ocean</span>
|
||
<button>&#9998;</button>
|
||
</div>
|
||
<div class="stories">
|
||
<ul>
|
||
<li class="unread">
|
||
<p class="title">The <em>Ocean </em>is Sinking</p>
|
||
<p class="author">Kat Stropher</p>
|
||
</li>
|
||
<li class="reading">
|
||
<p class="title"><em>Ocean </em>life is brutal</p>
|
||
<p class="author">Surphy McBrah</p>
|
||
</li>
|
||
<li>
|
||
<p class="title">
|
||
Family friendly fun at the
|
||
<em>ocean </em>exhibit
|
||
</p>
|
||
<p class="author">Guy Prosales</p>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div class="story">
|
||
<h1>Ocean life is brutal</h1>
|
||
<p>
|
||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim
|
||
ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||
aliquip ex ea commodo consequat.
|
||
</p>
|
||
<p class="signature">Surphy McBrah</p>
|
||
</div>
|
||
<div class="autoupdate">
|
||
Auto update:
|
||
<input type="checkbox" />
|
||
</div>
|
||
</div></code></pre><p>...and some CSS <a href="https://zaceno.github.com/hatut/style.css">here</a>.</p><p>It looks like this:</p><p>We'll start by making Hyperapp render the HTML for us. Then we will
|
||
add dynamic behavior to all the widgets, including text input and dynamically fetching stories.</p><p>First, let's begin with the traditional "Hello World!"</p><h2 id="hello-world">Hello World</h2><p>Create this html file:</p><pre><code class="language-html"><!doctype html>
|
||
<html>
|
||
<head>
|
||
<link rel="stylesheet" href="https://zaceno.github.com/hatut/style.css">
|
||
<script type="module">
|
||
|
||
// -- IMPORTS --
|
||
|
||
import {h, app} from "https://unpkg.com/hyperapp?module"
|
||
|
||
|
||
|
||
// -- ACTIONS --
|
||
|
||
|
||
|
||
// -- VIEWS ---
|
||
|
||
|
||
|
||
// -- RUN --
|
||
|
||
app({
|
||
node: document.getElementById("app"),
|
||
view: () => h("h1", {}, [
|
||
"Hello ",
|
||
h("i", {}, "World!")
|
||
])
|
||
})
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
</body>
|
||
</html></code></pre><blockquote>
|
||
<p>The section structure outlined in the comments is not important. It's
|
||
just a suggestion for how to organize the code we'll be
|
||
adding throughout the tutorial.</p>
|
||
</blockquote><p>Open it in a browser, and you'll be greeted with an optimistic <strong>Hello <em>World!</em></strong>.</p><h2 id="view">View</h2><p>Let's step through what just happened.</p><h3 id="virtual-nodes">Virtual Nodes</h3><p>Hyperapp exports the <code>app</code> and <code>h</code> functions.
|
||
<code>h</code> is for creating <em>virtual nodes</em>, which is to say: plain javascript objects
|
||
which <em>represent</em> DOM nodes.</p><p>The result of </p><pre><code class="language-js">h("h1", {}, [
|
||
"Hello ",
|
||
h("i", {}, "World!")
|
||
])</code></pre><p>is a virtual node, representing</p><pre><code class="language-html"><h1>
|
||
Hello
|
||
<i>World!</i>
|
||
</h1></code></pre><h3 id="rendering-to-the-dom">Rendering to the DOM</h3><p><code>app</code> is the function that runs our app. It is called with a single argument - an object
|
||
which can take several properties. For now we're just concerned with <code>view</code> and <code>node.</code></p><p>Hyperapp calls the <code>view</code> function which tells it the DOM structure we want, in the form
|
||
of virtual nodes. Hyperapp proceeds to create it for us, replacing the node specified in <code>node</code>.</p><p>To render the HTML we want, change the <code>view</code> to:</p><pre><code class="language-js">view: () => h("div", {id: "app", class: "container"}, [
|
||
h("div", {class: "filter"}, [
|
||
" Filter: ",
|
||
h("span", {class: "filter-word"}, "ocean"),
|
||
h("button", {}, "\u270E")
|
||
]),
|
||
h("div", {class: "stories"}, [
|
||
h("ul", {}, [
|
||
h("li", {class: "unread"}, [
|
||
h("p", {class: "title"}, [
|
||
"The ",
|
||
h("em", {}, "Ocean"),
|
||
" is Sinking!"
|
||
]),
|
||
h("p", {class: "author"}, "Kat Stropher")
|
||
]),
|
||
h("li", {class: "reading"}, [
|
||
h("p", {class: "title"}, [
|
||
h("em", {}, "Ocean"),
|
||
" life is brutal"
|
||
]),
|
||
h("p", {class: "author"}, "Surphy McBrah"),
|
||
]),
|
||
h("li", {}, [
|
||
h("p", {class: "title"}, [
|
||
"Family friendly fun at the ",
|
||
h("em", {}, "ocean"),
|
||
" exhibit"
|
||
]),
|
||
h("p", {class: "author"}, "Guy Prosales")
|
||
])
|
||
])
|
||
]),
|
||
h("div", {class: "story"}, [
|
||
h("h1", {}, "Ocean life is brutal"),
|
||
h("p", {}, `
|
||
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||
elit, sed do eiusmod tempor incididunt ut labore et
|
||
dolore magna aliqua. Ut enim ad minim veniam, quis
|
||
nostrud exercitation ullamco laboris nisi ut aliquip
|
||
ex ea commodo consequat.
|
||
`),
|
||
h("p", {class: "signature"}, "Surphy McBrah")
|
||
]),
|
||
h("div", {class: "autoupdate"}, [
|
||
"Auto update: ",
|
||
h("input", {type: "checkbox"})
|
||
])
|
||
]),</code></pre><p>Try it out to confirm that the result matches the screenshot above.</p><blockquote>
|
||
<p>In many frameworks it is common to write your views/templates
|
||
using syntax that looks like HTML. This is possible with Hyperapp as well.
|
||
<a href="https://babeljs.io/docs/en/babel-plugin-transform-react-jsx">JSX</a> can compile a HTML-like syntax into <code>h</code> calls at build-time. If you'd rather
|
||
not use a build system, <a href="https://github.com/developit/htm">htm</a> does the same at run-time.</p>
|
||
<p>In this tutorial we'll stick with <code>h</code> to keep it simple and close to the metal.</p>
|
||
</blockquote><h3 id="composing-the-view-with-reusable-functions">Composing the view with reusable functions</h3><p>The great thing about using plain functions to build up our virtual DOM
|
||
is that we can break out repetitive or complicated parts into their own functions.</p><p>Add this function (in the "VIEWS" section):</p><pre><code class="language-js">const emphasize = (word, string) =>
|
||
string.split(" ").map(x => {
|
||
if (x.toLowerCase() === word.toLowerCase()) {
|
||
return h("em", {}, x + " ")
|
||
} else {
|
||
return x + " "
|
||
}
|
||
}) </code></pre><p>It lets you change this: </p><pre><code class="language-js"> ...
|
||
h("p", {class: "title"}, [
|
||
"The ",
|
||
h("em", {}, "Ocean"),
|
||
" is Sinking!"
|
||
]),
|
||
...</code></pre><p>into this:</p><pre><code class="language-js"> ...
|
||
h("p", {class: "title"}, emphasize("ocean",
|
||
"The Ocean is Sinking"
|
||
))
|
||
...</code></pre><p>Story thumbnails are repeated several times, so encapsulate
|
||
them in their own function:</p><pre><code class="language-js">const StoryThumbnail = props => h(
|
||
"li",
|
||
{class: {
|
||
unread: props.unread,
|
||
reading: props.reading,
|
||
}},
|
||
[
|
||
h("p", {class: "title"}, emphasize(props.filter, props.title)),
|
||
h("p", {class: "author"}, props.author)
|
||
]
|
||
)</code></pre><blockquote>
|
||
<p>The last example demonstrates a helpful feature of the <code>class</code> property. When
|
||
you set it to an object rather than a string, each key with a truthy value
|
||
will become a class in the class list.</p>
|
||
</blockquote><p>Continue by creating functions for each section of the view:</p><pre><code class="language-js">
|
||
const StoryList = props => h("div", {class: "stories"}, [
|
||
h("ul", {}, Object.keys(props.stories).map(id =>
|
||
StoryThumbnail({
|
||
id,
|
||
title: props.stories[id].title,
|
||
author: props.stories[id].author,
|
||
unread: !props.stories[id].seen,
|
||
reading: props.reading === id,
|
||
filter: props.filter,
|
||
})
|
||
))
|
||
])
|
||
|
||
const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
h("span", {class: "filter-word"}, props.filter),
|
||
h("button", {}, "\u270E")
|
||
])
|
||
|
||
const StoryDetail = props => h("div", {class: "story"}, [
|
||
props && h("h1", {}, props.title),
|
||
props && h("p", {}, `
|
||
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||
elit, sed do eiusmod tempor incididunt ut labore et
|
||
dolore magna aliqua. Ut enim ad minim veniam, qui
|
||
nostrud exercitation ullamco laboris nisi ut aliquip
|
||
ex ea commodo consequat.
|
||
`),
|
||
props && h("p", {class: "signature"}, props.author)
|
||
])
|
||
|
||
const AutoUpdate = props => h("div", {class: "autoupdate"}, [
|
||
"Auto update: ",
|
||
h("input", {type: "checkbox"})
|
||
])
|
||
|
||
const Container = content => h("div", {class: "container"}, content)
|
||
</code></pre><p>With those the view can be written as:</p><pre><code class="language-js">view: () => Container([
|
||
Filter({
|
||
filter: "ocean"
|
||
}),
|
||
StoryList({
|
||
stories: {
|
||
"112": {
|
||
title: "The Ocean is Sinking",
|
||
author: "Kat Stropher",
|
||
seen: false,
|
||
},
|
||
"113": {
|
||
title: "Ocean life is brutal",
|
||
author: "Surphy McBrah",
|
||
seen: true,
|
||
},
|
||
"114": {
|
||
title: "Family friendly fun at the ocean exhibit",
|
||
author: "Guy Prosales",
|
||
seen: true,
|
||
}
|
||
},
|
||
reading: "113",
|
||
filter: "ocean"
|
||
}),
|
||
StoryDetail({
|
||
title: "Ocean life is brutal",
|
||
author: "Surphy McBrah",
|
||
}),
|
||
AutoUpdate(),
|
||
])</code></pre><p>What you see on the page should be exactly the same as before, because we haven't
|
||
changed what <code>view</code> returns. Using basic functional composition, we were able to make
|
||
the code a bit more manageable, and that's the only difference.</p><h2 id="state">State</h2><p>With all that view logic broken out in separate functions, <code>view</code> is starting to look like
|
||
plain <em>data</em>. The next step is to fully separate data from the view.</p><p>Add an <code>init</code> property to your app, with this pure data:</p><pre><code class="language-js"> init: {
|
||
filter: "ocean",
|
||
reading: "113",
|
||
stories: {
|
||
"112": {
|
||
title: "The Ocean is Sinking",
|
||
author: "Kat Stropher",
|
||
seen: false,
|
||
},
|
||
"113": {
|
||
title: "Ocean life is brutal",
|
||
author: "Surphy McBrah",
|
||
seen: true,
|
||
},
|
||
"114": {
|
||
title: "Family friendly fun at the ocean exhibit",
|
||
author: "Guy Prosales",
|
||
seen: true,
|
||
}
|
||
}
|
||
},</code></pre><p>The value of <code>init</code> becomes the app's <em>state</em>. Hyperapp calls <code>view</code> with the state
|
||
as an argument, so it can be reduced to:</p><pre><code class="language-js"> view: state => Container([
|
||
Filter(state),
|
||
StoryList(state),
|
||
StoryDetail(state.reading && state.stories[state.reading]),
|
||
AutoUpdate(state),
|
||
]),</code></pre><p>Visually, everything is <em>still</em> the same. If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-1-gq662">here</a></p><h2 id="actions">Actions</h2><p>Now that we know all about rendering views, it's finally time for some <em>action</em>!</p><h3 id="reacting-to-events-in-the-dom">Reacting to events in the DOM</h3><p>The first bit of dynamic behavior we will add is so that when you click
|
||
the pencil-button, a text input with the filter word appears.</p><p>Add an <code>onClick</code> property to the button in the filter view:</p><pre><code class="language-js">const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
h("span", {class: "filter-word"}, props.filter),
|
||
h("button", { onClick: StartEditingFilter }, "\u270E") // <---
|
||
])</code></pre><p>This makes Hyperapp bind a click-event handler on the button element, so
|
||
that when the button is clicked, an action named <code>StartEditingFilter</code> is
|
||
<em>dispatched</em>. Create the action in the "ACTIONS" section:</p><pre><code class="language-js">const StartEditingFilter = state => ({...state, editingFilter: true})</code></pre><p>Actions are just functions describing transformations of the state.
|
||
This action keeps everything in the state the same except for <code>editingFilter</code>
|
||
which it sets to <code>true</code>.</p><p>When Hyperapp dispatches an action, it replaces the old state with the new
|
||
one calculated using the action. Then the DOM is modified to match what the
|
||
view returns for this new state.</p><p>When <code>editingFilter</code> is true, we want to have a text input instead of a span with the filter word. We can express this in the <code>Filter</code> view using a
|
||
ternary operator (<code>a ? b : c</code>).</p><pre><code class="language-js">const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
|
||
props.editingFilter // <---
|
||
? h("input", {type: "text", value: props.filter}) // <---
|
||
: h("span", {class: "filter-word"}, props.filter),
|
||
|
||
h("button", { onClick: StartEditingFilter }, "\u270E")
|
||
])</code></pre><p>Now, when you click the pencil button the text input appears. But we still need to add
|
||
a way to go back. We need an action to <code>StopEditingFilter</code>, and a button to dispatch it.</p><p>Add the action:</p><pre><code class="language-js">const StopEditingFilter = state => ({...state, editingFilter: false})</code></pre><p>and update the <code>Filter</code> view again:</p><pre><code class="language-js">const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
|
||
props.editingFilter
|
||
? h("input", {type: "text", value: props.filter})
|
||
: h("span", {class: "filter-word"}, props.filter),
|
||
|
||
props.editingFilter // <---
|
||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||
: h("button", {onClick: StartEditingFilter}, "\u270E"), // <---
|
||
])</code></pre><p>When you click the pencil button, it is replaced with a check-mark button that can take you back to the first state.</p><h3 id="capturing-event-data-in-actions">Capturing event-data in actions</h3><p>The next step is to use the input for editing the filter word. Whatever we
|
||
type in the box should be emphasized in the story-list.</p><p>Update the <code>Filter</code> view yet again:</p><pre><code class="language-js">const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
|
||
props.editingFilter
|
||
? h("input", {
|
||
type: "text",
|
||
value: props.filter,
|
||
onInput: SetFilter, // <----
|
||
})
|
||
: h("span", {class: "filter-word"}, props.filter),
|
||
|
||
props.editingFilter
|
||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||
: h("button", {onClick: StartEditingFilter}, "\u270E"),
|
||
])</code></pre><p>This will dispatch the <code>SetFilter</code> action everytime someone types in the input. Implement the action like this:</p><pre><code class="language-js">const SetFilter = (state, event) => ({...state, filter: event.target.value})</code></pre><p>The second argument to an action is known as the <em>payload</em>. Actions
|
||
dispatched in response to an events on DOM elements receive the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event">event object</a> for a payload. <code>event.target</code> refers to the input element in the DOM, and
|
||
<code>event.target.value</code> refers to the current value entered into it.</p><p>Now see what happens when you erase "ocean" and type "friendly" instead:</p><h3 id="actions-with-custom-payloads">Actions with custom payloads</h3><p>Next up: selecting stories by clicking them in the list.</p><p>The following action sets the <code>reading</code> property in the state to a story-id, which amounts to "selecting" the story:</p><pre><code class="language-js">const SelectStory = (state, id) => ({...state, reading: id})</code></pre><p>It has a payload, but it's not an event object. It's a custom value telling us which
|
||
story was clicked. How are actions dispatched with custom payloads? – Like this:</p><pre><code class="language-js">
|
||
const StoryThumbnail = props => h(
|
||
"li",
|
||
{
|
||
onClick: [SelectStory, props.id], // <----
|
||
class: {
|
||
unread: props.unread,
|
||
reading: props.reading,
|
||
}
|
||
},
|
||
[
|
||
h("p", {class: "title"}, emphasize(props.filter, props.title)),
|
||
h("p", {class: "author"}, props.author)
|
||
]
|
||
)</code></pre><p>Instead of just specifying the action, we give a length-2 array with the action first and the custom payload second.</p><p>Selecting stories works now, but the feature is not quite done. When a story is selected,
|
||
we need to set its <code>seen</code> property to <code>true</code>, so we can highlight which stories the user has yet to read. Update the <code>SelectStory</code> action:</p><pre><code class="language-js">const SelectStory = (state, id) => ({
|
||
...state, // keep all state the same, except for the following:
|
||
reading: id,
|
||
stories: {
|
||
...state.stories, //keep stories the same, except for:
|
||
[id]: {
|
||
...state.stories[id], //keep this story the same, except for:
|
||
seen: true,
|
||
}
|
||
}
|
||
})</code></pre><p>Now, when you select a blue-edged story it turns yellow because it is selected, and when you select something else,
|
||
the edge turns gray to indicate you've read the story.</p><h3 id="payload-filters">Payload filters</h3><p>There's one little thing we should fix about <code>SetFilter</code>. See how it's dependent on the complex <code>event</code> object? It would be easier to test and reuse if it were simply:</p><pre><code class="language-js">const SetFilter = (state, word) => ({...state, filter: word})</code></pre><p>But we don't know the word beforehand, so how can we set it as a custom payload? Change the <code>Filter</code> view again (last time - I promise!):</p><pre><code class="language-js">const Filter = props => h("div", {class: "filter"}, [
|
||
"Filter:",
|
||
|
||
props.editingFilter
|
||
? h("input", {
|
||
type: "text",
|
||
value: props.filter,
|
||
onInput: [SetFilter, event => event.target.value], // <----
|
||
})
|
||
: h("span", {class: "filter-word"}, props.filter),
|
||
|
||
props.editingFilter
|
||
? h("button", {onClick: StopEditingFilter}, "\u2713")
|
||
: h("button", {onClick: StartEditingFilter}, "\u270E"),
|
||
])</code></pre><p>When we give a <em>function</em> as the custom payload, Hyperapp considers it a <em>payload filter</em> and passes the default
|
||
payload through it, providing the returned value as payload to the action.</p><blockquote>
|
||
<p>Payload filters are also useful when you need a payload that is a combination of custom data and event data</p>
|
||
</blockquote><p>If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-2-5yv34">here</a></p><h2 id="effects">Effects</h2><p>Until now, the list of stories has been defined in the state and doesn't change. What we really want is
|
||
for stories matching the filter to be dynamically loaded. When we click the check-mark button
|
||
(indicating we are done editing the filter), we want to query an API and display the stories it responds with.</p><h3 id="actions-can-return-effects">Actions can return effects</h3><p>Add this import (to the "IMPORTS" section):</p><pre><code class="language-js">import {Http} from "https:/unpkg.com/hyperapp-fx@next?module"</code></pre><p>Use the imported <code>Http</code> in the <code>StopEditingFilter</code> action like this:</p><pre><code class="language-js">const StopEditingFilter = state => [
|
||
{
|
||
...state,
|
||
editingFilter: false,
|
||
},
|
||
Http({ // <---
|
||
url: `https://zaceno.github.io/hatut/data/${state.filter.toLowerCase()}.json`, // <---
|
||
response: "json", // <---
|
||
action: GotStories, // <---
|
||
})
|
||
]</code></pre><p>The call to <code>Http(...)</code> does <em>not</em> immediately execute the API request. <code>Http</code> is an <em>effect creator</em>. It returns
|
||
an <em>effect</em> bound to the options we provided. </p><p>When Hyperapp sees an action return an array, it takes the first element of the array to be the new state, and the rest to
|
||
be <em>effects</em>. Effects are executed by Hyperapp as part of processing the action's return value.</p><blockquote>
|
||
<p>Hyperapp provides effect creators for many common situations. If you've got an unusual case or are working
|
||
with less common APIs you may need to implement your own effects. Don't worry - it's easy! See the <a href="">API reference</a> for more information.</p>
|
||
</blockquote><h3 id="effects-can-dispatch-actions">Effects can dispatch actions</h3><p>One of the options we passed to <code>Http</code> was <code>action: GotStories</code>. The way this effect works is that when the response comes
|
||
back from the api, an action named <code>GotStories</code> (yet to be implemented) will be dispatched, with the response body as the payload.</p><p>The response body is in json, but the payload will be a javascript object, thanks to the parsing hint <code>response: "json"</code>. It will look like this (although the details depend on your filter of course):</p><pre><code class="language-js">{
|
||
"112": {
|
||
title: "The Ocean is Sinking",
|
||
author: "Kat Stropher",
|
||
},
|
||
"113": {
|
||
title: "Ocean life is brutal",
|
||
author: "Surphy McBrah",
|
||
},
|
||
"114": {
|
||
title: "Family friendly fun at the ocean exhibit",
|
||
author: "Guy Prosales",
|
||
}
|
||
}</code></pre><p>The job of <code>GotStories</code> is to load this data into the state, in place of the stories we already have there. As it
|
||
does, it should take care to remember which story was selected, and which stories we have seen, if they were already
|
||
in the previous state. This will be our most complex action yet, and it could look like this:</p><pre><code class="language-js">const GotStories = (state, response) => {
|
||
const stories = {}
|
||
Object.keys(response).forEach(id => {
|
||
stories[id] = {...response[id], seen: false}
|
||
if (state.stories[id] && state.stories[id].seen) {
|
||
stories[id].seen = true
|
||
}
|
||
})
|
||
const reading = stories[state.reading] ? state.reading : null
|
||
return {
|
||
...state,
|
||
stories,
|
||
reading,
|
||
}
|
||
}</code></pre><p>Try it out! Enter "life" in the filter input. When you click the check-mark button some new
|
||
stories are loaded – all with blue edges except for "Ocean life is brutal" because it is
|
||
still selected.</p><h3 id="running-effects-on-initialization">Running effects on initialization</h3><p>The next obvious step is to load the <em>initial</em> stories from the API as well. Change init to this:</p><pre><code class="language-js"> init: [
|
||
{
|
||
editingFilter: false,
|
||
autoUpdate: false,
|
||
filter: "ocean",
|
||
reading: null,
|
||
stories: {}, // <---
|
||
},
|
||
Http({ // <---
|
||
url: `https://zaceno.github.io/hatut/data/ocean.json`, // <---
|
||
response: 'json', // <---
|
||
action: GotStories, // <---
|
||
})
|
||
],</code></pre><p>Hyperapp treats the init-value the same way as it treats return values from actions. By adding the <code>Http</code> effect
|
||
in <code>init</code>, the app will fire the API request immediately, so we don't need the stories in the state from the start.</p><h3 id="tracking-state-for-asynchronous-effects">Tracking state for asynchronous effects</h3><p>If we could display a spinner while we wait for stories to load, it would make for a smoother user experience. To
|
||
do that, we will need a new state property to tell us if we're waiting for a repsonse - and
|
||
consequently wether or not to render the spinner.</p><p>Create this action: </p><pre><code class="language-js">const FetchStories = state => [
|
||
{...state, fetching: true},
|
||
Http({
|
||
url: `https://zaceno.github.io/hatut/data/${state.filter.toLowerCase()}.json`,
|
||
response: 'json',
|
||
action: GotStories,
|
||
})
|
||
]</code></pre><p>Instead of dispatching this action, we will use it to simplify <code>StopEditingFilter</code>:</p><pre><code class="language-js">const StopEditingFilter = state => FetchStories({...state, editingFilter: false})</code></pre><p>... and <code>init</code> as well:</p><pre><code class="language-js"> init: FetchStories({
|
||
editingFilter: false,
|
||
autoUpdate: false,
|
||
filter: "ocean",
|
||
reading: null,
|
||
stories: {},
|
||
}),</code></pre><p>Now, when <code>StopEditingFilter</code> is dispatched, <em>and</em> at initialization, the API call goes out and the
|
||
<code>fetching</code> prop is set to <code>true</code>. Also, notice how we refactored out the repetitive use of <code>Http</code>.</p><p>We also need to set <code>fetching: false</code> in <code>GotStories</code>:</p><pre><code class="language-js">const GotStories = (state, response) => {
|
||
const stories = {}
|
||
Object.keys(response).forEach(id => {
|
||
stories[id] = {...response[id], seen: false}
|
||
if (state.stories[id] && state.stories[id].seen) {
|
||
stories[id].seen = true
|
||
}
|
||
})
|
||
const reading = stories[state.reading] ? state.reading : null
|
||
return {
|
||
...state,
|
||
stories,
|
||
reading,
|
||
fetching: false, // <---
|
||
}
|
||
}</code></pre><p>With this, we know that when <code>fetching</code> is <code>true</code> we are waiting for a response, and should display
|
||
the spinner in the <code>StoryList</code> view:</p><pre><code class="language-js">const StoryList = props => h("div", {class: "stories"}, [
|
||
|
||
props.fetching && h("div", {class: "loadscreen"}, [ // <---
|
||
h("div", {class: "spinner"}) // <---
|
||
]), // <---
|
||
|
||
h("ul", {}, Object.keys(props.stories).map(id =>
|
||
StoryThumbnail({
|
||
id,
|
||
title: props.stories[id].title,
|
||
author: props.stories[id].author,
|
||
unread: !props.stories[id].seen,
|
||
reading: props.reading === id,
|
||
filter: props.filter
|
||
})
|
||
))
|
||
])</code></pre><p>When the app loads, and when you change the filter, you should see the spinner appear until the stories are loaded.</p><blockquote>
|
||
<p>If you aren't seeing the spinner, it might just be happening too fast. Try choking your network speed. In the Chrome
|
||
browser you can set your network speed to "slow 3g" under the network tab in the developer tools.</p>
|
||
</blockquote><p>If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-3-2mmug">here</a></p><h2 id="subscriptions">Subscriptions</h2><p>The last feature we'll add is to make our app periodically check for new stories matching the filter. There won't actually
|
||
be any because it's not a real service, but you'll know it's happening when you see the spinner pop up every five
|
||
seconds. </p><p>However, we want to make it opt-in. That's what the auto update checkbox at the bottom is for. We need a
|
||
property in the state to track wether the box is checked or not. </p><p>Change the <code>AutoUpdate</code> view:</p><pre><code class="language-js">const AutoUpdate = props => h("div", {class: "autoupdate"}, [
|
||
"Auto update: ",
|
||
h("input", {
|
||
type: "checkbox",
|
||
checked: props.autoUpdate, // <---
|
||
onInput: ToggleAutoUpdate, // <---
|
||
})
|
||
])</code></pre><p>and implement the <code>ToggleAutoUpdate</code> action:</p><pre><code class="language-js">const ToggleAutoUpdate = state => ({...state, autoUpdate: !state.autoUpdate})</code></pre><p>Now we've got <code>autoUpdate</code> in the state tracking the checkbox. All we need now, is to set up <code>FetchStories</code>
|
||
to be dispatched every five seconds when <code>autoUpdate</code> is <code>true</code>.</p><p>Import the <code>interval</code> <em>subscription creator</em>:</p><pre><code class="language-js">import {interval} from "https://unpkg.com/@hyperapp/time?module"</code></pre><p>Add a <code>subscriptions</code> property to your app, with a conditional declaration of <code>interval</code> like this:</p><pre><code class="language-js"> subscriptions: state => [
|
||
state.autoUpdate && interval(FetchStories, {delay: 5000})
|
||
]</code></pre><p>Hyperapp will call <code>subscriptions</code> every time the state changes. If it notices a
|
||
new subscription, it will be started, or if one has been removed it will be stopped.</p><p>The options we passed to the <code>interval</code> subscription state that <code>FetchStories</code> should be dispatched every five seconds. It
|
||
will start when we check the auto update box, and stop when it is unchecked.</p><blockquote>
|
||
<p>As with effects, Hyperapp offers subscriptions for the most common cases, but you
|
||
may need to implement your own. Refer to the <a href="">API reference</a>. Again, it is no big deal - just not in scope for this tutorial.</p>
|
||
</blockquote><p>If you'd like to see a working example of the final code, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-4-8u9q8">here</a></p><h2 id="conclusion">Conclusion</h2><p>Congratulations on completing this Hyperapp tutorial!</p><p>Along the way you've familiarized yourself with
|
||
the core concepts: <em>view</em>, <em>state</em>, <em>actions</em>, <em>effects</em> & <em>subscriptions</em>. And that's really all you need to
|
||
build any web application.</p> |