Modular Hyperapp - Part 5
Zacharias Enochsson
Posted on August 18, 2020
NOTE: this article series is superceded by this article
In part 4 we defined a module that encapsulated the actions and view of a counter. It looked like this.
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('span', {class: 'counter'}, [
h('button', {onclick: model.Decrement}, [ text('-') ]),
text(model.value),
h('button', {onclick: model.Increment}, [ text('+') ]),
])
export {init, model, view}
We saw how an app can integrate this module with just three easy steps:
-
init
the counter state in some slot in the app state. - get the
model
function for a counter instance, by providing thegetter
andsetter
for accessing the state. - put the counter view somewhere in the main view, passing it
model(currentState)
as an argument.
The pattern is easy, but still practically useless, since a counter can't affect the app and vice versa. It would just sit there being a counter with no purpose (unless you happen to find counters amusing).
Useful App Modules
In this article we'll expand on the pattern to make useful app modules. We'll keep using the counter as an example, but we need to make it a bit more advanced first. We'll need it to respect a maximum and minimum value:
//...
const increment = (x, max) => Math.min(max, x + 1)
const decrement = (x, min) => Math.max(min, x - 1)
const model = ({getter, setter, min, max}) => {
const Increment = state =>
setter(state, increment(getter(state), max))
const Decrement = state =>
setter(state, decrement(getter(state), min))
//...
})
//...
Alright! Now: how can we make this module interact with the rest of the app?
Writing
The module already encapsulates its own views and actions for users to increment or decrement the counter. But could we allow the app to increment/decrement programatically, from some other action? Yes, we just need to provide functions for that. Here's what I suggest:
//...
//the function formerly known as model:
const wire = ({getter, setter, min, max}) => {
const _increment = state =>
setter(state, increment(getter(state), max))
const _decrement = state =>
setter(state, decrement(getter(state), min))
const Increment = state => _increment(state)
const Decrement = state => _decrement(state)
return {
increment: _increment,
decrement: _decrement,
model: state => ({
value: getter(state),
Increment,
Decrement,
})
}
}
//...
export {init, wire, view}
I changed the name of model
to wire
. Instead of returning a function, it returns an object containing the original function, now named model
. That's just moving things around and not important. The big news is the increment
and decrement
functions now also returned from wire
.
increment
and decrement
are like primitive transforms, but defined using getter
and setter
so they operate on the full app state. I call this kind of functions "mapped transforms".
Because mapped transforms operate on the full app state, they can be called from within any action anywhere in the app, without violating the principle of loose coupling.
Reading
Another module might want to access the value of a counter. It's already possible by calling foo.model(state).value
, but if we like we could also add a more direct way:
//...
const wire = ({getter, setter}) => {
const _value = state => getter(state)
//...
return {
value: _value,
increment: _increment,
decrement: _decrement,
model: state => ({
value: _value(state),
Increment,
Decrement,
})
}
}
//...
That way another action could call foo.value(state)
to get the value, rather than foo.model(state).value
.
Reacting
Finally, an app using our module might need to react to something that happen in our module. In this particular case, we want let the app to know when a user incremented or decremented the value.
By requiring the app to pass an onIncrement
and an onDecrement
function alongside getter
and setter
, it tells the module what to do in those events.
//...
const wire = ({
getter,
setter,
min,
max,
onIncrement,
onDecrement
}) => {
//...
const Increment = state => {
let old = _value(state)
state = _increment(state)
if (_value(state) === old) return state
else return onIncrement(state)
}
const Decrement = state => {
let old = _value(state)
state = _increment(state)
if(_value(state) === old) return state
else return onDecrement(state)
}
//...
}
//...
We only need to call onIncrement
when a user clicked the "+" button (not for calls to increment
by other actions). And we only call it if the value actually changed. If it was already at the max then no increment really happened so we leave it alone.
Wiring Modules Together
Now that our module has inputs and outputs, let's wire together an app with it!
Say we are making an app for creating character-sheets for a role-playing game (the old-school kind with dice, pencil & paper, et.c.) A character can have between one and five points for each of the four attributes: strength (STR), dexterity (DEX), intelligence (INT) and charisma (CHA). You have 12 points to spend on your character attributes however you see fit.
We'll use counters for the spending/unspending of points. That means we'll hook the counters together so that we never spend more than 12 points total.
Here's one way we could do it:
import * as counter from './counter.js'
const init = () => ({
points: 8,
strength: counter.init(1),
dexterity: counter.init(1),
intelligence: counter.init(1),
charisma: counter.init(1),
})
const spendPoint = (state, orElse) =>
!state.points
? orElse(state)
: {
...state,
points: state.points - 1,
}
const returnPoint = state => ({
...state,
points: state.points + 1
})
const strength = counter.wire({
getter: state => state.strength,
setter: (state, strength) => ({...state, strength}),
onIncrement: state =>
spendPoint(state, strength.decrement),
onDecrement: returnPoint,
min: 1,
max: 5,
})
//... ditto for dexterity, intelligence & charisma
And just to prove it works:
Of course you could reduce repetition in those counter-wirings with just a little more effort. I opted to leave as is to keep it more clear.
Closing Remarks, Part 5
Our introduction of "mapped transforms" seem close to calling methods on objects in OOP. And our talk about "reacting to events" in modules might lead you to think of emitting and listening for events.
I used that language because it fits the mental model of interacting modules. But what is really going on?
When the user clicks the "+" button in the STR-column, then if the strength points were already 5, nothing happens.
If strength was just 2 it would go up to 3, and
onIncrement
is called.onIncrement
, is a reference to the mapped transformspendPoint
.If there happen not to be any more points to spend, the
orElse
function is called.orElse
refers to the mapped transformstrength.decrement
. That brings the strength value back down to 2.
All of those functions were defined in separate places for the sake of modularity. But they are actually executed as a sequence of state transformations entirely within the Increment
action of the strength counter.
Before we can wrap up (in part 7) we should take a look at how subscriptions and effects play in to things. That will be the topic of part 6.
Posted on August 18, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.