Effects as data for Hyperapp.
Clone or download
Latest commit dba7e39 Sep 9, 2018

README.md

Hyperapp FX

Build Status codecov npm Slack

A Hyperapp higher-order app enabling you to write your effects as data, inspired by Elm Commands. Using effects as data will give your app benefits in several areas.

  • Purity — All of your actions become pure functions, since you are merely returning data describing the effect(s) to run on your behalf later, rather than directly performing them yourself.
  • Testing — pure functions are amazingly easy to test, since they always return the same data for the same arguments.
  • Debugging — data is more useful for troubleshooting at runtime since it can be logged or serialized and transmitted for remote forensics. Debug async and other effectful code without touching a debugger.

Getting Started

Here's a taste of how to use two of the most common effects for firing actions and making HTTP requests. The app displays inspiring quotes about design, fetching a new quote each time the user clicks on the current one. Go ahead and try it online here.

import { h, app } from "hyperapp"
import { withFx, http, action } from "hyperapp-fx"

const state = {
  quote: "Click here for quotes"
}

const actions = {
  getQuote: () => [
    action("setQuote", "..."),
    http(
      "https://quotesondesign.com/wp-json/posts?filter[orderby]=rand&filter[posts_per_page]=1",
      "quoteFetched"
    )
  ],
  quoteFetched: ([{ content }]) => action("setQuote", content),
  setQuote: quote => ({ quote })
}

const view = state => <h1 onclick={action("getQuote")}>{state.quote}</h1>

withFx(app)(state, actions, view, document.body)

Installation

Install with npm or Yarn.

npm i hyperapp-fx

Then with a module bundler like parcel, rollup or webpack, use as you would anything else.

import { withFx } from "hyperapp-fx"

If you don't want to set up a build environment, you can download Hyperapp FX from a CDN like unpkg.com and it will be globally available through the window.hyperappFx object.

<script src="https://unpkg.com/hyperapp-fx"></script>

Overview

withFx

EffectsConfig = {
  [effectName]: (
    props: object,
    getAction: (name: string) => Action
  ) => undefined
}
withFx = App => App | EffectsConfig => App => App

This higher-order app function enables actions to return arrays which later will be run as effects.

Example:

import { withFx } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => [
    // ... effects go here
  ],
  bar: () => // or a single effect can go here
}

withFx(app)(state, actions).foo()

Custom effects

For custom effects pass an object to withFx before composing with your app:

import { withFx } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  /*
    You will probably want to write a helper function for returning these
    similar to the built-in effects
  */
  foo: () => [
    /*
      type of effect for effects data
      must match key used in custom effect object below
    */
    "custom",
    {
      // ... props go here
    }
  ]
}

withFx({
  // key in this object must match type used in effect data above
  custom(props, getAction) {
    /*
      use props to get the props used when creating the effect
      use getAction for firing actions when appropriate
    */
  }
})(app)(state, actions).foo()

Reusing an existing effect type will override the built-in one.

Effects data

EffectTuple = [type: string, props: object]
Effect = EffectTuple | EffectTuple[] | Effect[]

Effects are always represented as arrays. For a single effect this array represents a tuple containing the effect type string and an object containing the properties of this effect. For multiple effects each array element is either an effect tuple or an array of these tuples, which may be nested. This means that effects are composeable. Empty arrays are treated as a no-op effect and skipped.

action

action = (name: string, data?: any) => EffectTuple

Describes an effect that will fire another action, optionally with data.

Example:

import { withFx, action } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => [
    action("bar", { message: "hello" }),
    action("baz", { message: "hola" }),
    // ... other effects
  ],
  bar: data => {
    // data will have { message: "hello" }
  },
  baz: data => {
    // data will have { message: "hola" }
  }
}

withFx(app)(state, actions).foo()

Note that you may also use a single action effect without an array wrapper and that nested actions may be called by separating the slices with dots:

import { withFx, action } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => action("bar.baz", { message: "hello" }),
  bar: {
    baz: data => {
      // data will have { message: "hello" }
    }
  }
}

withFx(app)(state, actions).foo()

This same convention follows for all the other effects as well.

Also note that action (and other effects) may be used for handler props in your view:

import { withFx, action } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: data => {
    /*
      data will have { message: "hello" }
      when the button is clicked
    */
  }
}

const view = () =>
  h("button", {
    onclick: action("foo", { message: "hello" })
  })

withFx(app)(state, actions, view, document.body)

frame

frame = (action: string) => EffectTuple

Describes an effect that will call an action from inside requestAnimationFrame, which is also where the render triggered by the action will run. A relative timestamp will be provided as the action data. If you wish to have an action that continuously updates the state and rerenders inside of requestAnimationFrame (such as for a game), remember to include another frame effect in your return.

Example:

import { withFx, action, frame } from "hyperapp-fx"

const state = {
  time: 0,
  delta: 0
}

const actions = {
  init: () => frame("update"),
  update: time => [
    action("incTime", time),

    /*
      ...
      Other actions to update the state based on delta time
      ...

      End with a recursive frame effect to perform the next update
    */
    frame("update")
  ],
  incTime: time => ({ time: lastTime, delta: lastDelta }) => ({
    time,
    delta: time && lastTime ? time - lastTime : lastDelta
  })
}

withFx(app)(state, actions).init()

delay

delay = (duration: number, action: string, data?: any) => EffectTuple

Describes an effect that will call an action after a delay using setTimeout, optionally with data.

Example:

import { withFx, delay } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  startTimer: () => delay(60000, "alarm", { name: "minute timer" }),
  alarm: data => {
    /*
      This action will run after a minute delay
      data will have { name: "minute timer" }
    */
  }
}

withFx(app)(state, actions).startTimer()

time

time = (action: string) => EffectTuple

Describes an effect that will provide the current timestamp to an action using performance.now. The timestamp will be provided as the action data.

Example:

import { withFx, time } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => time("bar"),
  bar: timestamp => {
    // use timestamp
  }
}

withFx(app)(state, action).foo()

log

log = (...args: any[]) => EffectTuple

Describes an effect that will call console.log with arguments. Useful for development and debugging. Not recommended for production.

Example:

import { withFx, log } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => log(
    "string arg",
    { object: "arg" },
    ["list", "of", "args"],
    someOtherArg
  )
}

withFx(app)(state, actions).foo()

http

http = (url: string, action: string, options?: object) => EffectTuple

Describes an effect that will send an HTTP request using fetch and then call an action with the response. If you are using a browser from the Proterozoic Eon like Internet Explorer you will want one of the available fetch polyfills. An optional options parameter supports the same options as fetch plus the following additional properties:

Property Usage Default
response Specify which method to use on the response body. "json"
error Action to call if there is a problem making the request or a not-ok HTTP response. Same action as defined for success

Example HTTP GET request with a JSON response:

import { withFx, http } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => http("/data", "dataFetched"),
  dataFetched: data => {
    // data will have the JSON-decoded response from /data
  }
}

withFx(app)(state, actions).foo()

Example HTTP GET request with a text response:

import { withFx, http } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => http(
    "/data/name",
    "textFetched",
    { response: "text" }
  ),
  textFetched: data => {
    // data will have the response text from /data
  }
}

withFx(app)(state, actions).foo()

Example HTTP POST request using JSON body and response that handles errors:

import { withFx, http } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  login: form => http(
    "/login",
    "loginComplete",
    {
      method: "POST",
      body: form,
      error: "loginError"
    }
  ),
  loginComplete: loginResponse => {
    // loginResponse will have the JSON-decoded response from POSTing to /login
  },
  loginError: error => {
    // please handle your errors...
  }
}

withFx(app)(state, actions).login()

event

event = (action: string) => EffectTuple

Describes an effect that will capture DOM Event data when attached to a handler in your view. The originally fired event will be provided as the action data.

import { withFx, event } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  click: clickEvent => {
    // clickEvent has the props of the click event
  }
}

const view = () =>
  h("button", {
    onclick: event("click")
  })

withFx(app)(state, actions, view, document.body)

Custom effects recieve an event prop if they were fired from within an event handler. This event value is particularly useful when implementing logic for accessing the current DOM element in Hyperapp lifecyle events such as oncreate and ondestroy.

Example custom effect to focus an input on create:

import { withFx, event } from "hyperapp-fx"

const focus = () => ["focus"];
const fx = {
  focus({ event }) {
    // Side effects are isolated to only within fx
    event.focus();
  }
};

const state = {
  text: "hi"
};

const actions = {
  type: ({ target: { value: text } }) => ({ text })
};

const view = state => (
  <main>
    <h1>{state.text}</h1>
    <input
      value={state.text}
      oninput={event("type")}
      oncreate={focus()}
    />
  </main>
);

withFx(fx)(app)(state, actions, view, document.body);

keydown

keydown = (action: string) => EffectTuple

Describes an effect that will capture keydown events for your entire document. The KeyboardEvent will be provided as the action data.

Example:

import { withFx, keydown } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  init: () => keydown("keyPressed"),
  keyPressed: keyEvent => {
    // keyEvent has the props of the KeyboardEvent
  }
}

withFx(app)(state, actions).init()

keyup

keyup = (action: string) => EffectTuple

Describes an effect that will capture keyup events for your entire document. The KeyboardEvent will be provided as the action data.

Example:

import { withFx, keyup } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  init: () => keyup("keyReleased"),
  keyReleased: keyEvent => {
    // keyEvent has the props of the KeyboardEvent
  }
}

withFx(app)(state, actions).init()

random

random = (action: string, min?: number, max?: number) => EffectTuple

Describes an effect that will call an action with a randomly generated number within a range. If provided the range will be [min, max) or else the default range is [0, 1). The random number will be provided as the action data.

Use Math.floor if you want a random integer instead of a floating-point number. Remember the range will be max exclusive, so use your largest desired int + 1.

Example:

import { withFx, random } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  // We use the max of 7 to include all values of 6.x
  foo: () => random("rollDie", 1, 7),
  rollDie: randomNumber => {
    const roll = Math.floor(randomNumber)
    // roll will be an int from 1-6
  }
}

withFx(app)(state, actions).foo()

debounce

debounce = (wait: number, action: string, data?: any) => EffectTuple

Describes an effect that will call an action after waiting for a delay to pass. The delay will be reset each time the action is called.

Example:

import { withFx, debounce } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  waitForLastInput: input => debounce(
    500,
    "search",
    { query: input }
  ),
  search: data => {
    /*
      This action will run after waiting
      for 500ms since the last call
      It will only be called once
      data will have { query: "hyperapp" }
    */
  }
}

const main = withFx(app)(state, actions)
main.waitForLastInput("hyper")
main.waitForLastInput("hyperapp")

throttle

throttle = (rate: number, action: string, data?: any) => EffectTuple

Describes an effect that will call an action at a maximum rate. Where rate is one call per rate miliseconds

Example:

import { withFx, throttle } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  doExpensiveAction: param => throttle(
    500,
    "calculate",
    { foo: param }
  ),
  calculate: data => {
    /*
      This action will only run once per 500ms
      It will be run twice
      data will receive { foo: "foo" } and { foo: "baz" }
    */
  }
}

const main = withFx(app)(state, actions)
main.doExpensiveAction("foo")
main.doExpensiveAction("bar")
setTimeout(function() {
  main.doExpensiveAction("baz")
})

fxIf

EffectConditional = [boolean, EffectTuple]
effectsIf = EffectConditional[] => EffectTuple[]

Convert an array of [boolean, EffectTuple]s into a new array of effects where the boolean evaluated to true. This provides compact syntatic sugar for conditionally firing effects.

Example:

import { withFx, fxIf, action } from "hyperapp-fx"

const state = {
  // ...
}

const actions = {
  foo: () => ({ running }) =>
    fxIf([
      [true, action("always")],
      [false, action("never")],
      [running, action("ifRunning")],
      [!running, action("ifNotRunning")]
    ])
}

withFx(app)(state, actions).foo()

License

Hyperapp FX is MIT licensed. See LICENSE.