Skip to content

Instantly share code, notes, and snippets.

@skanne
Last active March 26, 2024 11:09
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save skanne/62fc8a9c0701b579fe74bc3531cca04d to your computer and use it in GitHub Desktop.
Save skanne/62fc8a9c0701b579fe74bc3531cca04d to your computer and use it in GitHub Desktop.
A reference of Hyperapp 2 actions, effects and subscriptions and how they are declared and used.

Hyperapp 2 – Actions, Effects and Subscriptions

Learn about the function signatures of actions, effects, and subscriptions in Hyperapp 2 and how they are used in apps.

Actions

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.

Plain action

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,
})

Action with event object (from DOM events)

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,
})

Action with payload (number, string, boolean, object, array, ...)

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,
})

Curried action with payload (anything but a function)

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,
})

Action using payload creator function

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,
})

Action causing a side effect

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,
})

Action causing parallel side effects

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,
})

Sequence of actions and effects (one after the other)

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,
})

Effects

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

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, besides init, view and node). The value of that property is a function that receives the state 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,
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment