This is a utility for Hyperapp (v2.0.3 and above). It helps you structure your app as a composition of self-contained, reusable modules, while staying true to the single-state, one-way-data-flow paradigm.
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 directly to your scripts from CDB:
import {
makeMap,
mapVNode,
mapPass,
mapSubs,
} from 'https://unpkg.com/hyperapp-map'
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'
An action map is a transformation of an action into another action. It is a function which takes an action as its single argument, and returns a new action. Typically, it is used to:
- Make an action which performs the same transform as an original action on a particular portion of the state.
- Make an action which extends/alters the transform of an original action
For instance, we might have actions:
const Increment = state => state + 1
const Decrement = state => state - 1
but if the state is shaped as: {foo: 'bar', counter: 7}
, those actions in and of themselves are useless. Instead of explicitly redefining every action to understand the shape of the state, we can define an action map counterMap
that lets us reuse our simple, primitive actions:
const CounterIncrement = counterMap(Increment)
const CounterDecrement = counterMap(Decrement)
Assuming counterMap
works correctly, then CounterIncrement({foo: 'bar', counter:7})
will return {foo: 'bar', counter: 8}
makeMap
allows you to define actionMaps, by passing it two functions. The first is the "extractor": given the full state, it should return the state we want to pass to the action being transformed. The second is the "merger", which is itself shaped like an action which receives the result of calling the transformed action with the extracted state, as the payload.
Our counterMap
above could be defined as:
const counterMap = makeMap(
state => state.counter,
(state, counter) => ({...state, counter})
)
"Big deal!", I hear you say. "I could just as easily define counterMap
like this, without a library":
const counterMap = action =>
(state, payload) =>
({
...state,
counter: action(state.counter, payload)
})
Yes, you could. But what if the action you want to transform returns effects? What if Increment
were implemented as:
//increment and after two seconds decrement again:
const Increment = state => [state + 1, delay(Decrement, 2000)]
const Decrement = state => state - 1
Action maps defined with makeMap
will handle this. Effects returned by transformed actions, will be returned from the transformed action. Additionally, any actions dispatched from those effects will be mapped using the same action map (since this is usually what you want, as in the case above).
The merger, being shaped like an action, can also return effects but these will be unmapped (also probably what you want).
Making your own action maps that can handle effects takes a lot more code than the simple example earlier, and that is why makeMap
exists.
If you define your actions as primitive and straightforward as Increment = state => state + 1
, then you are free to use them in multiple places in multiple apps. But you'll need to use an action map to add the specific logic required for each use.
Let's say you have some kind of game where you can "pay coins" from a "purse". You can pay even though your purse is empty, but in that case you will have to loan the coin from a bank instead. Paying means using Decrement
on your purse, but we need to add the lending logic on top of that. You can do this with an action map.
const purseMap = makeMap(
state => state.purse,
(state, purse) => ({
...state,
debt: purse >= 0 ? state.debt : state.debt - purse,
purse: purse >= 0 ? purse : 0
})
)
const PayOne = purseMap(Decrement)
So far we've covered defining action maps, and using them to define actions specific to our app, from generic, primitive actions. This can get quite tedius in a large app.
You can save yourself a lot of boilerplate, by letting your view's event handlers refer to primitive actions, and then mapping all the actions referred in a section of the view with an appropriate action map. That is what mapVNode
does. It walks through a vnode recursively, applying a given map to all the actions defined for the event handlers within.
Let's say you've defined a counter.js
module:
import {h} from 'hyperapp'
const init = 0
const Increment = state => state + 1
const Decrement = state => state - 1
const view = state =>
h('p', {}, [
h('button', { onclick: Decrement }, '-'),
state,
h('button', { onclick: Increment }, '+'),
])
export {init, view}
In your main app, you can now render the working counter view in your main view, without ever explicitly needing to deal with any of its actions:
import {h, app} from 'hyperapp'
import {makeMap, mapVNode} from 'hyperapp-map'
import * as counter from './counter.js'
import * as foo from './foo.js'
const counterMap = makeMap(
state => state.counter,
(state, counter) => ({...state, counter})
)
const fooMap = makeMap(
state => state.foo,
(state, foo) => ({...state, foo})
)
app({
init: {
foo: foo.init,
counter: counter.init,
},
view: state => h('main', {}, [
mapVNode(counterMap, counter.view(state.counter)),
mapVNode(fooMap, foo.view(state.foo)),
])
})
You can even integrate the logic of the counter with the logic of foo, simply by changing the implementation of counterMap
:
const counterMap = makeMap(
state => state.counter,
(state, counter) => ({
...state,
counter: Math.min(foo.getMax(state.foo), counter)
})
)
(Here, we assumed the foo
module can provide an upper limit we want to apply to the counter, through the method getMax
)
For convenience mapVNode
can take an array of vnodes instead of just a single vnode, and will apply the map to all the vnodes in the array.
When you've defined a stateful component that is able to "contain" some generic content – for example a fancy, stateful pop-up window, a transition-group, et c – then you typically don't want the content to be mapped.
mapPass
simply takes an array of vnodes, and for each vnode it grants a one-time "shield of protection" against applications of mapVNode
. Use it to protect generic content from being mapped by the map that should apply to actions in the container.
For example:
mapVNode(popupMap, popup.view(
state.popup,
{title: 'Edit Profile'},
mapPass([
mapVNode(profileMap, profile.form(
state.profile
))
])
))
In this example, any action x
in profile.form
will not become popupMap(formMap(x))
– that would have broken the application, probably! Thanks to mapPass
protecting profile.form
from being mapped by popupMap
, x
will simply be profileMap(x)
which is likely what we need.
Thanks to mapVNode
we can map actions in bulk without explicitly referring to them, or modules needing to export them, even. But there is one more source of action-dispatching we need to consider, before we are completely covered: subscriptions.
mapSubs
will take an array of subscriptions, and return the same array with any actions referred to in any of the subscriptions' options having been mapped by the given action map.
For example, you might want your counter module to listen to global keyboard events for incrementing and decrementing:
...
const HandleKey = (state, key) => (
key === 'ArrowUp' ? Increment
: key === 'ArrowDown' ? Decrement
: state
)
const subscriptions= state => [ OnKeyDown([HandleKey, eventKey]) ]
export {init, view, subscriptions}
In the main module, use it as such:
app({
...
subscriptions: state => [
...mapSubs(counterMap, counter.subscriptions(state.counter))
]
})
Normally, inlining a transforming function like that in the subscriptions is dangerous, since it means that the subscriptions properties will be different each state-update, which will cause the subscriptions to restart continually. This case is safe, however, since counterMap
is defined using makeMap
. Action maps defined with makeMap
are memoized so that repeated mappings of the same action will yield the same mapped action instance every time.
... tbc ...