Modular Hyperapp - Part 2
Zacharias Enochsson
Posted on August 17, 2020
NOTE: this article series is superceded by this article
In the first part of this series, we acknowledged that modules can be tricky. In particular, it's not easy to know from the start what modules you'll need down the road.
Thankfully, Hyperapp doesn't force you to think about modules up front. You simply start writing your app in the most straightforward way possible, with all the code in a single file.
It is only when that main file gets large and unwieldy that you need to start looking around for potential modules to break out. Usually you will start looking in the view, because that is the code that tends to grow the fastest.
View components
To give an example of breaking out a piece of view into a module, let's start with this app, which contains a counter:
import {app, h, text} from 'https://unpkg.com/hyperapp'
const Increment: state => ({
...state,
counter: state.counter + 1
})
const Decrement: state => ({
...state,
counter: state.counter - 1
})
app({
init: {counter: 0},
view: state => h('main', {}, [
h('h1', {}, [text('My Counter:')]),
h('p', {class: 'counter'}, [
h('button', {onclick: Decrement}, [ text('-') ]),
text(state),
h('button', {onclick: Increment}, [ text('+') ]),
])
]),
node: document.getElementById('app')
})
You wouldn't actually bother breaking out modules from an app this simple, but a realistic example would be harder to follow. Just pretend it is messier than it really is ;)
Since each node is defined by a call to h
, breaking out just the counter is as easy as cut-n-pasteing the node you want into a new function:
const counterView = state => h('p', {class: 'counter'}, [
h('button', {onclick: Decrement}, [ text('-') ]),
text(state.counter),
h('button', {onclick: Increment}, [ text('+') ]),
])
I call functions like this "view components" – or "views" for short – because they are composable bits of view. (In the Hyperapp community they are often called "components")
This counterView
allows us to express the main view in a more compact way:
state => h('main', {}, [
h('h1', {}, [ text('My Counter:') ]),
counterView(state),
])
View components in modules
When we move counterView
into a separate module (counter.js
) it loses its references to Increment
and Decrement
, since they are still in the scope of the main module (main.js
). There are three ways to fix that:
Option A: Move the actions to counter.js
as well
The downside is that the actions are dependent on the full app state, so we would need to keep revisiting counter.js
to update Increment
and Decrement
as we add new features.
Moreover we couldn't reuse this module for a second counter in the app, because it is bound specifically to these two actions.
Option B: Export the actions from main.js
and import them in counter.js
This makes counter.js
dependent on main.js
, and has the same reusability issue as option A.
Option C: Pass the actions as arguments to counterView
This is the one we'll go with.
Speaking of which, we can't have counterView
dependent on the full app state as an argument. It should expect the most concise and well defined set of arguments that provide just the values and actions it needs. It could look something like this:
// this is counter.js
import {h, text} from 'https://unpkg.com/hyperapp'
const view = ({value, Increment, Decrement}) =>
h('p', {class: 'counter'}, [
h('button', {onclick: Decrement}, [ text('-') ]),
text(value), // <--- !!! not `state.counter`
h('button', {onclick: Increment}, [ text('+') ]),
])
export {view}
Models
With that, main.js
becomes:
import {app, h, text} from 'https://unpkg.com/hyperapp'
import {view as counterView} from './counter.js'
const Increment = state => ({
...state,
counter: state.counter + 1
})
const Decrement = state => ({
...state,
counter: state.counter - 1
})
app({
init: {counter: 0},
view: state => h('main', {}, [
h('h1', {}, [text('My Counter:')]),
counterView({
value: state.counter, // <--
Increment,
Decrement,
})
]),
node: document.getElementById('app')
})
The object {value, Increment, Decrement}
is what I call the "model" for this particular view. It is up to the main view to map the current state and in-scope actions to the required model.
That's a bit more effort than just counterView(state)
but modularization is about saving effort later at the cost of a bit more code now.
Reusability
With our counter in such a loosely coupled module, we can make it fancier with animated SVGs and what not, only by editing counter.js
. More importantly, we can add a second counter with the same look but different behavior – without changing or duplicating counter.js
!
import {app, h, text} from 'https://unpkg.com/hyperapp'
import {view as counterView} from './counter.js'
const IncrA: state => ({...state, A: state.A + 1})
const DecrA: state => ({...state, A: state.A - 1})
const IncrB: (state, x) => ({...state, B: state.B + x})
const DecrB: (state, x) => ({...state, B: state.B - x})
app({
init: {A: 0, B: 0},
view: state => h('main', {}, [
h('h1', {}, [text('My Counter:')]),
counterView({
value: state.A,
Increment: IncrA,
Decrement: DecrA,
}),
h('h1', {}, [text('My Other Counter:')]),
counterView({
value: state.B,
Increment: [IncrB, 3],
Decrement: [DecrB, 3],
}),
]),
node: document.getElementById('app')
})
Conclusion, Part 2
In summary: Manage your complex view by breaking it apart into view components, in separate modules. If those get too big, break them down further.
A view component takes a model as its argument, which is an object containing all of the values, as well as all of the actions it needs.
The most effective approach is to target repetitive chunks of view, as well as large chunks that can be hidden behind concise models.
That should keep your view code in check as your app continues to grow. Taming the other side of your app – the business logic – is what we'll focus on for the rest of the series.
Posted on August 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.