DEV Community

loading...
Cover image for Modular Hyperapp - Part 5

Modular Hyperapp - Part 5

zaceno profile image Zacharias Enochsson ・5 min read

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 the getter and setter 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 transform spendPoint.

  • If there happen not to be any more points to spend, the orElse function is called.

  • orElse refers to the mapped transform strength.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.

Discussion (0)

pic
Editor guide