2.0.0 • Published 5 years ago

hyperapp-map v2.0.0

Weekly downloads
2
License
MIT
Repository
github
Last release
5 years ago

Hyperapp Map

This is a utility for Hyperapp (v2.0.3 and above). It lets you structure your app by composing self-contained, reusable modules with minimal boilerplate, while staying true to the single-state, one-way-data-flow paradigm.

Installation

Minified IIFE from CDN in script tag

Add this script tag to your HTML

<script src="https://unpkg.com/hyperapp-map/dist/hyperappmap.js"></script>

It will export hyperappmap in your global scope. It is an object containing the exported functions:

const { makeMap, mapVNode, mapPass, mapSubs } = hyperappmap

Import as ES module from CDN

Import directly to your scripts from CDB:

import {
    makeMap,
    mapVNode,
    mapPass,
    mapSubs,
} from 'https://unpkg.com/hyperapp-map'

Install into bundled project from npm

If you're bundling your app, you can install the package via

npm i hyperapp-map

And then import in your scripts like this:

import { makeMap, mapVNode, mapPass, mapSubs } from 'hyperapp-map'

Introduction by Example

Let's say you defined a self contained counter module:

const init = 0

export const Increment = state => state + 1
export const Decrement = state => state - 1

export const view = state =>
    h('p', {}, [
        h('button', { onclick: Decrement }, '-'),
        state,
        h('button', { onclick: Increment }, '+'),
    ])

Now you want to integrate it as a part of a larger app with other moving parts:

app.js

import * as foo from './foo.js'
import * as counter from './counter.js'

app({
    init: {
        foo: foo.init,
        counter: counter.init,
    },
    view: state =>
        h('main', {}, [foo.view(state.foo), counter.view(state.counter)]),
    node: document.getElementById('app'),
})

Unfortunately, that won't work, because the state given to the counter actions (Increment, for example) will be the app's full state {foo: ..., counter: 0} - not simply the expected 0.

The counter actions would need to be defined as:

const Increment = state => ({ ...state, counter: state.counter + 1 })
const Decrement = state => ({ ...state, counter: state.counter - 1 })

but if we changed the implementation of counter.js to fit this particular app, it would no longer be truly self contained and reusable. Imagine, for example if we wanted a second counter in the app –– how would we define the actions then?

While the actions in counter.js should be strictly limited to defining operations on counter state, app.js has the knowledge of how to extract current counter state from current full state and how to merge next counter state to produce the next full state. Combine this knowledge with the exported Increment to define an action that works in this particular app:

const CounterIncrement = state => ({
    ...state,
    counter: counter.Increment(state.counter),
})

Since we want to do the same for Decrement we abstract out the action-map:

const counterMap = action => state => ({
    ...state,
    counter: action(state.counter),
})
const CounterIncrement = counterMap(counter.Increment)
const CounterDecrement = counterMap(counter.Decrement)

Here counterMap is an "action-map", which is to say a function that defines an action from another action. This particular action-map restricts the scope of the given action to the counter prop of the full state.

Defining action-maps is the first thing hyperapp-map can help you with, through the makeActionMap function. Our counterMap could as well have been defined like this:

const counterMap = makeActionMap(
    //how to get counter state from full state:
    state => state.counter,

    // how to merge the resulting counter state with the full state:
    (state, counter) => ({ ...state, counter })
)

In this case there is no real benefit, since the first counterMap definition isn't much more complex. But what one of the actions we want to map returns an effect? What if Increment were defined as:

//Increments the value, and two seconds later decrements it again:
const Increment = state => [state + 1, delay(Decrement, 2000)]

Since app.js shouldn't know any implementation details about counter.js, we can't technically assume there won't bee effects returned. Writing an action-mapper that handles effect return values, mapping the actions dispatched from those effects in the same way, is nontrivial. But makeActionMap we can define such actions.

But making action-maps is just half the problem. Getting mapped actions in to the view, is the other half of the problem. And this is where mapVNode comes in. It takes an acition-map and a virtual node, scans through the entirety of the virtual node and replaces any actions, with actions mapped with the given map.

Now app.js can look like this:

import {makeMap, mapVNode} from 'hyperapp-map'
import * as counter from './counter.js'
import * as foo from './foo.js'

const counterMap = makeMap(x => x.counter, (x, counter) => ({...x, counter}))
const fooMap = makeMap(x => x.foo, (x, foo) => ({...x, foo}))

app({
    init: {counter: counter.init, foo: foo.init},
    view: state => h('main', {}, [
        mapVNode(counterMap, counter.view(state.counter))
        mapVNode(fooMap, foo.view(state.foo))
    ])
})

Using this method you may eventually find yourself at an impasse. What if you have some stateful component which is a container of somethig, like a modal dialog window that can be dragged around a window but can contain views from other actions. What if you have:

...
view: state => h('main', {}, [
    mapVNode(
        modalMap,
        modal.view(state.modal, {title: "Play with the counter"}, [
            mapVNode(
                counterMap,
                counter.view(state.counter)
            )
        ])
    )
])
...

With this structure, all the actions in counter.view will be transformed as:

action => modalMap(counterMap(action)

...which will make them not work.

This is the reason mapPass exists. It takes an array of vnodes (or a single one) and grants each action therein a one-time shield of protection from mapping. It means they will be exempt from mapping by the next map out (only that one).

Hence, this will work as exepcted:

view: state =>
    h('main', {}, [
        mapVNode(
            modalMap,
            modal.view(
                state.modal,
                { title: 'Play with the counter' },
                mapPass([mapVNode(counterMap, counter.view(state.counter))])
            )
        ),
    ])

The action map in the counter.view is now:

action => modalMap(mapPass(counterMap(action)))

And since mapPass cancels out modalMap in this case, all that is left is counterMap, which is what we wanted.

Finally, what if a module exports subscriptions? Then there is mapSubs, which takes a a map and an array of subscriptions. Each one will have its actions mapped by the given map. There is no equivalent to mapPassfor subscriptions because it isn't necessary.