Modular Hyperapp - Part 4
Zacharias Enochsson
Posted on August 18, 2020
NOTE: this article series is superceded by this article
In part 1, I brought up the Internal Space Station as an illustration of the idea of modules. I described how the ISS is not a massive structure, but rather an assemblage of self-sufficient segments that had been built and tested in isolation on earth. Once ready, they were shot in to space and snapped together.
Applying the analogy to developing an app, one would develop and test each feature on it's own like a "mini-app". Then build the actual app by hooking the features together with preferably not too much code.
Recap
Let's say we wanted to make such a mini-app for a counter. One that could easily be hooked in to another app. Given the patterns we've gone over so far, what would it look like?
In part 2 we talked about breaking out a view, and in part 3 we talked about breaking out the primitive transforms. We also mentioned the need for an init function and queries in order to keep the way we represent the counter-state a secret. It could look something like this:
//this is counter.js
import {h, text} from 'https://unpkg.com/hyperapp'
//initializer
const init = x => x
//primitive transforms
const increment = x => x + 1
const decrement = x => x - 1
//query function
const getValue = x => x
//view
const view = model => h('p', {class: 'counter'}, [
h('button', {onclick: model.Decrement}, [ text('-') ]),
text(model.value),
h('button', {onclick: model.Increment}, [ text('+') ]),
])
export {init, increment, decrement, getValue, view}
And what does hooking it in to an app look like? Say we have an app with a value foo
somewhere in the state, and that is what we want to use the counter for. It would look like this:
import {app, h, text} from 'https://unpkg.com/hyperapp'
import * as counter from 'counter.js'
IncrementFoo = state => ({
...state,
foo: counter.increment(state.foo)
})
DecrementFooBar = state => ({
...state,
foo: counter.decrement(state.foo)
})
//...many more actions related to other things
app({
init: {/* ...lots of stuff */},
view: state => h('main', {}, [
//...
counter.view({
value: counter.getValue(state.foo),
Increment: IncrementFoo,
Decrement: DecrementFoo,
}),
//...
]),
node: document.getElementById('app'),
})
Hmm... this isn't exactly bad but it's far from what I was envisioning when I talked about modules "snapping together".
The thing is, even though we simplified the implementation of actions by breaking out the details (primitive transforms), every action and model our app ever needs will have to be defined here in this one central place.
Setters and Getters
The problem with breaking out the actions and models is that they need to know how to find foo
in the full app state. If modules had that knowledge, it would make them all tightly coupled to eachother. Tightly coupled modules are considered harmful.
What we can do, is define the state-accessor-logic separately from the actions:
const getter = state => state.foo
const setter = (state, newFoo) => ({...state, foo: newFoo})
Action-definitions can use those functions instead of explicit access:
const IncrementFoo = state =>
setter(state, counter.increment(getter(state)))
Dynamically Defined Actions
With the accessor functions separated from the action definitions, we can move the action definitions in to counter.js
:
//this is counter.js
import {h, text} from 'https://unpkg.com/hyperapp'
const init = x => x
const increment = x => x + 1
const decrement = x => x - 1
const model = ({getter, setter}) => {
const Increment = state =>
setter(state, increment(getter(state)))
const Decrement = state =>
setter(state, decrement(getter(state)))
return state => ({
value: getter(state),
Increment,
Decrement,
})
}
const view = model => h('p', {class: 'counter'}, [
h('button', {onclick: model.Decrement}, [ text('-') ]),
text(model.value),
h('button', {onclick: model.Increment}, [ text('+') ]),
])
export {init, model, view}
Hooking up that module looks like this:
import {app, h, text} from 'https://unpkg.com/hyperapp'
import * as counter from 'counter.js'
const foo = counter.model({
getter: state => state.foo,
setter: (state, newFoo) => ({...state, foo: newFoo})
})
//...
app({
init: {/* ...lots of stuff */},
view: state => h('main', {}, [
//...
counter.view(foo(state)),
//...
]),
node: document.getElementById('app'),
})
Now that's more like it!
Adding a second counter to the app is a piece of cake:
import {app, h, text} from 'https://unpkg.com/hyperapp'
import * as counter from 'counter.js'
const foo = counter.model({
getter: state => state.foo,
setter: (state, newFoo) => ({...state, foo: newFoo})
})
const bar = counter.model({
getter: state => state.bar,
setter: (state, newBar) => ({...state, bar: newBar})
})
//...
app({
init: {/* ...lots of stuff */},
view: state => h('main', {}, [
//...
counter.view(foo(state)),
//...
counter.view(bar(state)),
//...
]),
node: document.getElementById('app'),
})
App Modules
It would be equally easy to hook up counter.js
up to an empty app for verification during development. And if you want to change how counters work – for instance adding a third button that increments by 2 – you can do all that in counter.js
without fear of breaking anything outside.
I call this sort of modules which encapsulate domain related actions and views "app modules", because they define all the "parts" of an app.
Closing Remarks, Part 4
Unfortunately this pattern is not complete. Unless there is some way for app modules to interact, apps will just be a collections of independent widgets. In part 5 I'll expand this pattern, adding connectors so that we can hook modules together.
Posted on August 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.