Learn about the function signatures of actions, effects, and subscriptions in Hyperapp 2 and how they are used in apps.
Actions:
- Are declared as constant arrow functions (
const ActionFunction = (s, p) => ns
). - Should have names written in
PascalCase
. - Return a new state (or an unchanged state).
- Shouldn't do complicated computations (outsource those to helper functions), just simple state transformations/transitions.
- Are used either in event handlers in the view or as dispatchable action items of effects.
This is the simplest form of an action. It takes the state and returns an updated state.
Pattern: const Action = state => newState
Example:
const ToggleMode = state => ({ ...state, darkMode: !state.darkMode })
app({
init: {},
view: state => (
<body>
<div
style={{
backgroundColor: '#bada55',
padding: '10px',
filter: state.darkMode ? 'invert(1) hue-rotate(180deg)' : null,
}}
>
<div>Current mode: {state.darkMode ? 'dark' : 'light'}</div>
<button onclick={ToggleMode}>Toggle between dark and light mode</button>
</div>
</body>
),
node: document.body,
})
If directly assigned to an event handler (just the function reference), the second argument passed to the action when the event is triggered is the event object.
Pattern: const Action = (state, event) => newState
Example:
const UpdateFirstName = (state, event) => ({ ...state, firstName: event.target.value })
app({
init: {},
view: state => (
<body>
<input oninput={UpdateFirstName} placeholder="Your first name" />
{state.firstName && <div>First name: {state.firstName}</div>}
</body>
),
node: document.body,
})
If the action is assigned to an event handler as the first item of a tuple, the second item of the tuple is passed to the action as the second argument when the event is triggered.
Pattern: const Action = (state, payload) => newState
Example:
const GiveAnswer = (state, answer) => ({ ...state, answer })
app({
init: {},
view: state => (
<body>
<button onclick={[GiveAnswer, 42]}>Answer to Life, the Universe and the Rest</button>
{state.answer && <div>The answer is: {state.answer}</div>}
</body>
),
node: document.body,
})
You can also use function currying to pass a payload to the action.
Pattern: const Action = payload => state => newState
Example:
const IncrementBy = inc => state => ({ ...state, counter: state.counter + inc })
app({
init: { counter: 0 },
view: state => (
<body>
<div>Counter: {state.counter}</div>
<button onclick={IncrementBy(1)}>Add 1 more</button>
<button onclick={IncrementBy(5)}>Add 5 more</button>
</body>
),
node: document.body,
})
If the second item of an event handler tuple is a function, that function is passed the event object, and whichever that function returns is taken as payload for the action when the event is triggered.
Pattern: const Action = (state, returnedPayload) => newState
Example:
const ToggleActive = (state, isActive) => ({ ...state, isActive })
const targetChecked = event => event.target.checked
app({
init: {},
view: state => (
<body>
<label>
Is active?
<input type="checkbox" checked={state.isActive} oninput={[ToggleActive, targetChecked]} />
</label>
</body>
),
node: document.body,
})
If an action returns a tuple of a new state and a nested effect/properties tuple consisting of an effect function reference and properties (i.e. payload) for the effect, the effect function is called after the state change. The effect in turn may dispatch one or many actions (usually passed in one of the properties or as the property itself).
Pattern: const Action = (state, nothingOrEventOrPayload) => [newState, [effectFnRef, effectProperties]]
Example:
// action with effect:
const Save = state => [{ ...state, saving: true }, saveToDatabase(state.formData, DoneSaving)]
// plain action:
const DoneSaving = state => ({ ...state, saving: false })
// effect creator:
const saveToDatabase = (formData, nextAction) => [
// effect function:
async (dispatch, { data, action }) => {
await DB.save(data)
dispatch(action)
},
// payload for effect:
{
data: formData,
action: nextAction,
},
]
// some simulated database:
const DB = {
save(data) {
return new Promise(resolve => setTimeout(resolve, 1000))
},
}
app({
init: { formData: 'Some Form Data' },
view: state => (
<body>
{/* controls for formData */}
<button type="submit" onclick={Save} disabled={state.saving}>
Save
</button>
{state.saving && <span>Saving...</span>}
</body>
),
node: document.body,
})
These side effects may happen sequentially though, if they don't run asynchronously processed code (e.g. fetch()
, setTimeout()
, requestAnimationFrame()
, etc.) internally. The second item of the returned tuple is a list of effect/properties tuples.
Pattern:
const Action = (state, nothingOrEventOrPayload) => [
newState, [
[effectFnRef1, props1],
[effectFnRef2, props2],
[effectFnRef3, props2],
]
]
or better with effect creators (see Effects):
const Action = (state, nothingOrEventOrPayload) => [
newState, [
effectCreator1(props1),
effectCreator2(props2),
effectCreator3(props3),
]
]
Here effects are grouped in a list, but the may equally well just be passed as further items in the outer list. So like:
const Action = (state, nothingOrEventOrPayload) => [
newState,
[effectFnRef1, props1],
[effectFnRef2, props2],
[effectFnRef3, props2],
]
Example:
const SendNotifications = state => [
{ ...state, notifying: { parent: true, children: true, siblings: true } },
notifyParents(state => ({ ...state, notifying: { ...notifying, parents: false } })),
notifyChildren(state => ({ ...state, notifying: { ...notifying, children: false } })),
notifySiblings(state => ({ ...state, notifying: { ...notifying, siblings: false } })),
]
// or broken down to multiple actions:
const SendNotifications = state => [
{ ...state, notifying: { parents: true, children: true, siblings: true } },
notifyParents(NotifiedParents),
notifyChildren(NotifiedChildren),
notifySiblings(NotifiedSiblings),
]
const NotifiedParents = state => ({ ...state, notifying: { ...notifying, parents: false } })
const NotifiedChildren = state => ({ ...state, notifying: { ...notifying, children: false } })
const NotifiedSiblings = state => ({ ...state, notifying: { ...notifying, siblings: false } })
// or even DRYer with an action creator function:
const SendNotifications = state => [
{ ...state, notifying: { parents: true, children: true, siblings: true } },
notifyParents(NotifiedParents),
notifyChildren(NotifiedChildren),
notifySiblings(NotifiedSiblings),
]
const Notified = whom => state => ({ ...state, notifying: { ...notifying, [whom]: false } })
const NotifiedParents = Notified('parents')
const NotifiedChildren = Notified('children')
const NotifiedSiblings = Notified('siblings')
// some effect creators returning admittedly extremely simplified effects (for brevity):
const notifyParents = nextAction => [dispatch => dispatch(nextAction)]
const notifyChildren = nextAction => [dispatch => dispatch(nextAction)]
const notifySiblings = nextAction => [dispatch => dispatch(nextAction)]
app({
init: {},
view: state => (
<body>
<button onclick={SendNotifications} />
{state.notifying && (
<div>
notifying
{state.notifying.parents && <span>parents... </span>}
{state.notifying.children && <span>children... </span>}
{state.notifying.siblings && <span>siblings... </span>}
</div>
)}
</body>
),
node: document.body,
})
If you want to make sure that actions and effects are handled sequentially, you can chain actions and their effects such that an effect dispatches the next action (passed as an effect property).
Pattern:
Action = state => [
newState,
[firstEffectFnRef, state => [
newState,
[secondEffectFnRef, state => [
newState,
[thirdEffectFnRef, state => newState]
]]
]]
]
or better with effect creators:
Action = state => [
newState,
firstEffectCreator(state => [
newState,
secondEffectCreator(state => [
newState,
thirdEffectCreator(state => newState)
])
])
]
taken apart into single actions and effects (which is better anyhow in order to avoid the Pyramid of Doom)
FirstAction = state => [
newState,
[firstEffectFnRef, SecondAction]
]
SecondAction = state => [
newState,
[secondEffectFnRef, ThirdAction]
]
ThirdAction = state => [
newState,
[thirdEffectFnRef, FinalAction]
]
FinalAction = state => newState
firstEffectFnRef = (dispatch, SecondAction) => {
/* do something with DOM, requests or such, then eventually: */
dispatch(SecondAction)
}
secondEffectFnRef = (dispatch, ThirdAction) => {
/* do something with DOM, requests or such, then eventually: */
dispatch(ThirdAction)
}
thirdEffectFnRef = (dispatch, FinalAction) => {
/* do something with DOM, requests or such, then eventually: */
dispatch(FinalAction)
}
Example:
const NextStep = state => [
{ ...state, validating: true },
firstValidate(state => [
{ ...state, validating: false, saving: true },
thenSave(state => [
{ ...state, saving: false },
lastlyProceed(state => { ...state, step: state.step + 1 })
])
]),
]
// or broken down to multiple actions:
const NextStep = state => [
{ ...state, validating: true },
firstValidate(DoneValidatingNowSave)
]
const DoneValidatingNowSave = state => [
{ ...state, validating: false, saving: true },
thenSave(DoneSavingNowProceed)
]
const DoneSavingNowProceed = state => [
{ ...state, saving: false },
lastlyProceed(ProceedToNextStep)
]
const ProceedToNextStep = state => { ...state, step: state.step + 1 }
// some effect creators returning admittedly extremely simplified effects (for brevity):
const firstValidate = actionAfterValidating => [dispatch => {/*validate...*/; dispatch(actionAfterValidating)}]
const thenSave = actionAfterSaving => [dispatch => {/*save...*/; dispatch(actionAfterSaving)}]
const lastlyProceed = actionAfterProceeding => [dispatch => {/*proceed...*/; dispatch(actionAfterProceeding)}]
app({
init: { step: 1, formData: [...formDataStep1, ...formDataStep2] },
view: state => (
<body>
<h1>Step {state.step}</h1>
{/* controls for formDataStep1 */}
<button type='submit' onclick={NextStep} disabled={state.validating || state.saving}>Next</button>
{state.validating && (<span>Validating...</span>)}
{state.saving && (<span>Saving...</span>)}
</body>
),
node: document.body,
})
An effect:
- Is a 2-tuple composed of an effect implementation function reference and its properties (i.e. the payload for the effect).
- Its implementation function is declared as a constant arrow function (
const effectFunction = (d, p) => {}
). - Should have a name written in
camelCase
(true for both effect implementation and creator functions).
Effect implementation functions take a dispatch function and properties (usually an object) as arguments.
If the effect should dispatch further actions, the properties then must contain one or many action function references which are passed to the dispatch function (as first argument) at the convenience of the effect. E.g. for an effect that uses setTimeout()
to delay an action by one second, the basic effect would
look something like this:
const deferOneSecondEffect = (dispatch, action) => setTimeout(() => dispatch(action), 1000)
Then, with a triggering action (const Action = state => [newState, [effect, NewAction]]
) and a
follow-up action, this could look like:
const StartShowing = state => [{ showing: true }, [deferOneSecondEffect, StopShowing]]
const StopShowing = state => ({ showing: false })
app({
init: {},
view: state => (
<body>
<div>
<button onclick={StartShowing}>Show box for one second</button>
</div>
{state.showing && <div class="showing">This box is shown for one second.</div>}
</body>
),
node: document.body,
})
If the delay itself should be configurable, then the second argument would
need to be an object, e.g. props = { action, delay }
:
const deferEffect = (dispatch, { action, delay }) => setTimeout(() => dispatch(action), delay)
or an array, e.g. props = [ action, delay ]
:
const deferEffect = (dispatch, [action, delay]) => setTimeout(() => dispatch(action), delay)
An effect creator takes the properties and returns a tuple with an effect function and the properties:
const deferCreator = props => [deferEffect, props]
This can be further curried to generate multiple effect creators in a DRYer mannor (so to say an "effect creator creator"):
const effectCreator = effect => props => [effect, props]
Then, with two effects (one using setTimeout()
, the other using setInterval()
) the creators
are generated as follows:
const deferEffect = (dispatch, { action, delay }) => setTimeout(() => dispatch(action), delay)
const repeatEffect = (dispatch, { action, delay }) => setInterval(() => dispatch(action), delay)
const defer = effectCreator(deferEffect)
const repeat = effectCreator(repeatEffect)
And then used like this:
const StartShowing = state => [{ ...state, showing: true }, defer({ action: StopShowing, delay: 1000 })]
const StopShowing = state => ({ ...state, showing: false })
const StartCounting = state => [{ ...state, counting: true }, repeat({ action: Count, delay: 1000 })]
const Count = state => ({ ...state, counter: state.counter + 1 })
app({
init: {
counter: 0,
},
view: state => (
<body>
<div>
<button onclick={StartShowing}>Show box for one second</button>
</div>
{state.showing && <div class="showing">This box is shown for one second.</div>}
<div>
<button onclick={StartCounting} disabled={state.counting}>
Start counting every second
</button>
</div>
{state.counting && <div class="counting">{state.counter}</div>}
</body>
),
node: document.body,
})
You notice that the counter is started, but never stopped? That is because in this
example we don't have the handle returned by setInterval()
which allows us to
call clearInterval(intervalHandle)
. Also, the app doesn't have a chance to react
on outside events. It currently only reacts to events triggered by the user.
This is where Subscriptions come in handy.
Subscriptions:
-
Are a declarative abstraction layer for managing global events like window events and custom event streams such as clock ticks, geolocation changes, handling push notifications, and WebSockets in the browser.
-
Are passed to Hyperapp via the
app()
function as an additional property (subscriptions
, besidesinit
,view
andnode
). The value of that property is a function that receives thestate
and returns one or more subscriptions. -
Are conceptionally similar to effects. The difference is that a subscription (usually) returns another function which allows the subscription to be ended properly. Think of an event listener that needs to be removed when the subscription ends. Or a communication channel that must be torn down.
Hyperapp will call subscriptions to refresh subscriptions whenever the state changes. There you can conditionally add, update and remove subscriptions much the same way you do with elements in the view function.
If a new subscription appears in the array (i.e. the respective item in the subscription list isn't undefined/falsy, but a subscription tuple), it'll be started. When a subscription leaves the array, it'll be canceled. If any of its properties change, it'll be restarted.
A single subscription:
- Is a 2-tuple composed of a subscription implementation function reference and its properties.
- Its implementation function is declared as a constant arrow function (
const subscriptionFunction = (d, p) => {return f}
). - Should have a name written in
camelCase
(both implementation and creator functions).
Let's take the counter example and adapt it. This time we'll cache the return
value of setInterval()
and return a function that can clear the interval
again with the cached handle.
const repeatSubscription = (dispatch, { action, delay }) => {
const id = setInterval(() => dispatch(action), delay)
return () => clearInterval(id)
}
Then the app looks something like this:
const StartCounting = state => ({ ...state, counting: true })
const StopCounting = state => ({ ...state, counting: false })
const Count = state => ({ ...state, counter: state.counter + 1 })
app({
init: {
counter: 0,
},
view: state => (
<body>
<button onclick={!state.counting ? StartCounting : StopCounting}>
{!state.counting ? 'Start' : 'Stop'} counting every second
</button>
<div class="counting">{state.counter}</div>
</body>
),
subscriptions: state => [
state.counting && [repeatSubscription, { action: Count, delay: 1000 }],
],
node: document.body,
})
Notice that neither the StartCounting
nor the StopCounting
action have an
associated effect. The counter only starts counting if the check for the flag
state.counting
is true and thus starts the subscription repeatSubscription
with the associated effect Count
.
Subscription functions, just like effects, shouldn't be used in their plain form. Instead they should be used through a creator function.
const repeatSubscription = (dispatch, { action, delay }) => {
const id = setInterval(() => dispatch(action), delay)
return () => clearInterval(id)
}
const repeat = (action, delay) => [repeatSubscription, { action, delay }]
// rest omitted for brevity
app({
init,
view,
subscriptions: state => [
state.counting && repeat(Count, 1000),
],
node,
})
Let's add another subscription, which is "always on":
const readNumberSubscription = (dispatch, action) => {
const listener = ({ key }) => {
if (/[1-9]/.test(key)) {
dispatch(action, Number(key))
}
}
addEventListener('keypress', listener)
return () => removeEventListener('keypress', listener)
}
const readNumber = action => [readNumberSubscription, action]
// rest omitted for brevity
app({
init,
view,
subscriptions: state => [
state.counting && repeat(Count, 1000), // subscription depends on state.counting
readNumber(Increment), // "always on"
],
node,
})