Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

V2 Subscriptions #752

Closed
jorgebucaran opened this issue Aug 30, 2018 · 16 comments
Closed

V2 Subscriptions #752

jorgebucaran opened this issue Aug 30, 2018 · 16 comments
Labels
discussion docs You read it here first
Milestone

Comments

@jorgebucaran
Copy link
Owner

jorgebucaran commented Aug 30, 2018

Summary

Working with traditional event emitters requires a lot of complicated resource management like adding and removing event listeners, closing connections, clearing intervals—not to mention testing asynchronous code is hard. What happens when the source you are subscribed to shuts down? How do you cancel or restart a subscription?

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.

Think of subscriptions as "virtual DOM meets event streams". Events are represented using plain objects called subscriptions. When the program state changes we compare the new subscriptions against the old, compute their differences and rewire the underlying connections to match the desired state.

In this issue, I'll introduce everything you need to know about the upcoming Subscriptions API.

Background

Let's start with the good news. If you know how to attach on-event listeners to UI elements, you already know how subscriptions work!

const Increment = state => state + 1

app({
  init: 0,
  view: state => <button onClick={Increment}>Clicked {state} time(s)</button>,
  node: document.getElementById("app")
})

As long as the button remains in the view, Hyperapp will dispatch Increment every time the button is clicked.

In the next example, a Reset button is shown only after the Increment button is clicked once. Click it to set the count back to 0 and make the button go away.

const Reset = () => 0
const Increment = state => state + 1

app({
  init: Reset,
  view: state => (
    <main>
      <h1>{state}</h1>
      <button onClick={Increment}>Clicked {state} time(s)</button>
      {state > 0 && <button onClick={Reset}>Reset</button>}
    </main>
  ),
  node: document.getElementById("app")
})

The declarative view model allows us to add and remove elements dynamically, which in turn cancels any event listeners it was subscribed to.

Subscriptions work similarly to on-event listeners, but on a global level. Without subscriptions, we'd use the imperative DOM API: addEventListener, removeEventListener, setInterval, requestAnimationFrame, etc., to handle global events, window events, intervals, and repaint cycle events.

const toggleEventListener = (event, eventListener, shouldSubscribe) => {
  if (shouldSubscribe) {
    addEventListener(event, eventListener)
  } else {
    removeEventListener(event, eventListener)
  }
}

const MouseMoved = ({ x, y }) => {
  text.textContent = `${x},${y}`
}

toggleEventListener("mousemove", MouseMoved, true)

const text = document.body.appendChild(document.createElement("h1"))
const button = document.body.appendChild(document.createElement("button"))

let isTracking = true
button.textContent = "Toggle Subscription"
button.onclick = () => {
  toggleEventListener("mousemove", MouseMoved, (isTracking = !isTracking))
}

Subscriptions allow us to do the same in a declarative way.

import { h, app } from "hyperapp"
import { onMouseMove } from "@hyperapp/events"

const Toggle = state => ({ ...state, isTracking: !state.isTracking })
const MouseMoved = (state, { x, y }) => ({ ...state, position: `${x},${y}` })

app({
  init: {
    position: "",
    isTracking: true
  },
  view: state => (
    <main>
      <h1>{state.position}</h1>
      <button onClick={Toggle}>Toggle Subscription</button>
    </main>
  ),
  subscriptions: state => state.isTracking && onMouseMove(MouseMoved),
  node: document.getElementById("app")
})

Declarative subscriptions

We want to be notified when something happens in the outside world: mouse movements, keyboard presses, clock ticks, browser repaints, and so on. Subscriptions allow us to listen for such things.

A subscription is a plain object that identifies the type of the event you want to listen to, the action to dispatch with the event data and other data it needs. Not every subscription produces an Event. They can be used to model other event streams such as geolocation changes, time intervals and custom events.

To declare subscriptions we'll use the new subscriptions function. It receives the current state and returns one or more subscriptions. Like with effects (#750) you'll likely be using an existing subscription library to create subscriptions: @hyperapp/time, @hyperapp/mouse, @hyperapp/keyboard, etc.

import { app } from "hyperapp"
import { interval } from "@hyperapp/time"
import { Tick } from "./actions"

app({
  subscriptions: state => interval(Tick, { delay: 1000 })
})

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.

export const subscriptions = state => [
  state.isIntro || state.isLost || state.isWon
    ? onMouseClick(startGame)
    : state.isInPlay && [
        onAnimationFrame(tick),
        onKeyDown(keyPressed),
        onKeyUp(move)
      ]
]

If a new subscription appears in the array, we'll start the subscription. When a subscription leaves the array, we'll cancel it. If any of its properties change, we'll restart it. Otherwise, we'll skip to the next subscription until we've seen them all.

Examples

SVG Clock

const angle = t => (2 * Math.PI * t) / 60000
const Tick = (state, data) => data.time

app({
  init: Date.now(),
  view: state => (
    <svg viewBox="0 0 100 100" width="300px">
      <circle cx="50" cy="50" r="45" fill="#00caff" />
      <line
        x1="50"
        y1="50"
        x2={50 + 40 * Math.cos(angle(state))}
        y2={50 + 40 * Math.sin(angle(state))}
        stroke="#111"
      />
    </svg>
  ),
  subscriptions: state => interval()<Time.interval delay={1000} action={Tick} />,
  node: document.getElementById("app")
})

Countdown Clock

Try it here.

import { h, app } from "hyperapp"
import { interval } from "@hyperapp/time"

const timeToUnits = t => [t.getHours(), t.getMinutes(), t.getSeconds()]

const formatTime = (hours, minutes, seconds, use24) =>
  (use24 ? hours : hours > 12 ? hours - 12 : hours) +
  ":" +
  `${minutes}`.padStart(2, "0") +
  ":" +
  `${seconds}`.padStart(2, "0") +
  (use24 ? "" : ` ${hours > 12 ? "PM" : "AM"}`)

const posixToHumanTime = (time, use24) =>
  formatTime(...timeToUnits(new Date(time)), use24)

const Tick = (state, time) => ({
  ...state,
  time
})

const ToggleFormat = state => ({
  ...state,
  use24: !state.use24
})

const getInitialState = time => ({
  time,
  use24: false
})

app({
  init: getInitialState(Date.now()),
  view: state =>
    h("div", {}, [
      h("h1", {}, posixToHumanTime(state.time, state.use24)),
      h("fieldset", {}, [
        h("legend", {}, "Settings"),
        h("label", {}, [
          h("input", {
            type: "checkbox",
            checked: state.use24,
            onInput: ToggleFormat
          }),
          "Use 24 Hour Clock"
        ])
      ])
    ]),
  subscriptions: state => interval(Tick, { delay: 1000 }),
  node: document.getElementById("app")
})

Implementing subscriptions

If you want to implement your own subscription then either one (or more) of these things are happening:

  1. Hyperapp's core subscription libraries lack an important subscription. Solution: Request it.
  2. You've created a new service PhaserLink and there is no Hyperapp subscription for it, so you'll need to write one.
  3. You just want to write your own subscription.

Let's implement a few subscriptions to see how it's done. There are two parts to implementing your own subscription:

  • The constructor function takes an object with the properties required by the subscription and returns an object that Hyperapp understands.

  • The implementation function encapsulates the subscription setup: adding event listeners, installing a callback, etc. It receives what the constructor gives it and is responsible for dispatching actions to Hyperapp. It returns a function that knows how to unsubscribe from your event source so that Hyperapp can cancel the subscription if it needs to.

See also #750/implementing-effects

Time ticks

const timeFx = fx => (action, props) => [
  fx,
  { action: action, delay: props.delay }
]

export const interval = timeFx((dispatch, props) => {
  const id = setInterval(() => dispatch(props.action, Date.now()), props.delay)
  return () => clearInterval(id)
})

Mouse moves

const eventFx = name => {
  return (fx => action => [fx, { action: action }])((dispatch, props) => {
    const listener = event => dispatch(props.action, event)
    addEventListener(name, listener)
    return () => removeEventListener(name, listener)
  })
}

export const onMouseMove = eventFx("mousemove")
@jorgebucaran jorgebucaran added docs You read it here first discussion labels Aug 30, 2018
@selfup
Copy link
Contributor

selfup commented Aug 30, 2018

Excited to see this. I have this idea for using Effects/Subs for game loops and theoretically it should work quite well with canvas 😂

However my first experiment will be with the navigation.geolocation API 🎉

@okwolf
Copy link
Contributor

okwolf commented Aug 30, 2018

@selfup I have a subscription that implements an animation loop using requestAnimationFrame: https://github.com/hyperapp/fx/blob/HAv2/src/subs/Animation.js

@selfup
Copy link
Contributor

selfup commented Aug 31, 2018

Oh sweet! Glad I am not alone in those thoughts. I am tethering right now (travel) so I'll check it out tomorrow 🎉

@okwolf
Copy link
Contributor

okwolf commented Aug 31, 2018

If subscriptions are still the preferred option for interop, we need to document that.

@jorgebucaran
Copy link
Owner Author

@okwolf Good point. Subscriptions are the preferred option for interoperability now. In fact, I am not sure about returning the internal dispatch function either, so there's a vacancy open for returning something and I am taking ideas.

@selfup
Copy link
Contributor

selfup commented Sep 1, 2018

I would love for the internal dispatch to be exposed, but I can understand if you don't want to. Sometimes it's just easier to just call dispatch directly for that legacy code that just won't go away 😂

Thanks for bringing it up!

@okwolf seems that subs look like effects after all? The constructors at least. Anyways, still stoked to see what we discover as we play around with effects/subs/action for v2 🎉

@SteveALee
Copy link

SteveALee commented Sep 3, 2018

Just a thought but are subscription libraries and effects libraries separate or would you combine them into drivers as cyclejs does?

That then makes me wonder about async if http request + response would be effect + event. Again cycle does it that way but you then need IDs to tie the 2 together.

@jorgebucaran
Copy link
Owner Author

@SteveALee I'd like to group libraries by a common domain, e.g., @hyperapp/time should export both a delay effect and a tick subscription (names are still provisional at this moment).

@hyperapp/http should export a fetch effect that wraps the Fetch API (or xmlhttprequest). I don't know Cycle.js, but there will be no need for IDs. I have no idea what those would be used for.

Here is an example.

const Increment = (state, value) => state + value
const DelayIncrement = (state, { value, duration }) => [
  state,
  delay({ action: [Increment, value], duration })
]

app({
  init: 0,
  view: state => (
    <main>
      <h1>{state}</h1>
      <button onClick={[Increment, 2]}>Add 2 Now</button>
      <button onClick={[DelayIncrement, { value: 100, duration: 5000 /*ms*/ }]}>
        Add 100; 5s Later
      </button>
    </main>
  ),
  subscriptions: state => tick({ interval: 1000, action: [Increment, 1] }),
  container: document.body
})

2018-09-03 18 57 48


One more thing, aborting a fetch request is not possible with an effect. Effects model side-effects. Subscriptions model event streams. So, I imagine there could be a fetch subscription (subject to a better name) that lets you create or tap into a transfer stream which can be canceled at any time.

@SteveALee
Copy link

SteveALee commented Sep 3, 2018

@jorgebucaran

I'd like to group libraries by a common domain, e.g., @hyperapp/time should export both a delay effect and a tick subscription

Great - I think that's is best

The example is great. I was wondering if I might group my actions into a object as V1 does. Purely for stylistic reasons. Was there a reason you do NOT do that? other and needing ref with actions.Increment

Sorry for confusion re fetch. As long a Fetch effect is async that's all good (actually I need to see examples of async effects, I assume you have them). I've worked with many async comms protocols in systems dev and was really complicating the issues

So an alternative style of handling Async remote request / responses rather than effect returning a promise that 'completes' on the expected response is to completely decouple them (effect are then only ever output only with no return, and subscriptions handle all input).

  1. Generate the effect which sends the request
  2. Receive a event some time later when the response arrives (or not)

There is no wait of promise at all. The event just comes in. You can fire off multiple effects and then handle the events as the arrive (in any order). However you usually need to tie them together in the correct sequence (, like TCP/IP does it's packet matching), depending on the request / response protocol (perhaps multiple responses are possible for one request). So either the protocol lets you supply an ID in the request and returns it in the matching response. Or you need to handle them locally for the effect and event.

Cycle takes this approach, leaving user-land to manage the IDs so the Response is connected to the request (that was just sent, so no overlap)

Anyway with HTTP Req/Resp that is not necessary ASFAIK :)

So ignore me

@SteveALee
Copy link

onClick={[DelayIncrement, { value: 100, duration: 5000 /*ms*/ }]}

yay! Tuples :)

@guido4000
Copy link

This seems to be pretty similar to Reason subscriptions, which are very helpful.

https://github.com/reasonml/reason-react/blob/master/docs/subscriptions-helper.md

@jorgebucaran
Copy link
Owner Author

jorgebucaran commented Sep 4, 2018

Fascinating. I'm not familiar with Reason subscriptions, but you must be right @guido4000. I have mixed feelings about their API, and I am not sure where the similarities begin and end, but we're trying to accomplish the same thing and that's what matters.

V2 subscriptions are based on elm's subscriptions (and tasks). Like elm, each subscription can be added and removed conditionally, but we can express them using an array right off the bat (whereas in elm you need to use Sub.batch). Unlike elm, a deeply nested batch of subscriptions can be expressed using nested arrays and any falsy elements in the array denote a subscription should be canceled. We're holding firm on the functional programming front, but also embracing JavaScript dynamic nature.

Using the official effects/subscription libraries, @hyperapp/time, @hyperapp/geolocation, etc., will be the norm instead of rolling your own. Still, I designed the API as a user-facing API and not a low-level API, so creating them is a piece of cake.

@okwolf
Copy link
Contributor

okwolf commented Sep 7, 2018

So for the purposes of interoperability, do we have any best practices to recommend?

Would it make sense to have a subscription that adds a listener for CustomEvents and then dispatch those from outside of Hyperapp?

Or would it be better to use some sort of small event bus?

Perhaps some other approach I haven't considered yet?

@jorgebucaran
Copy link
Owner Author

Would it make sense to have a subscription that adds a listener for CustomEvents and then dispatch those from outside of Hyperapp?

Absolutely. This is an excellent way to start events inside Hyperapp from an external source, e.g., a React app co-existing in the same document.

Repository owner deleted a comment from sergey-shpak Sep 30, 2018
@jorgebucaran
Copy link
Owner Author

👋 @sergey-shpak Please post questions in a new issue or join the Slack for better average response times.

Repository owner locked as resolved and limited conversation to collaborators Sep 30, 2018
@jorgebucaran jorgebucaran added this to the V2 milestone Oct 3, 2018
@jorgebucaran jorgebucaran pinned this issue Dec 16, 2018
@jorgebucaran jorgebucaran changed the title V2 Subscriptions API V2 Subscriptions Mar 6, 2019
@jorgebucaran
Copy link
Owner Author

Corrected mistakes and other small errors.

Thanks, @lukejacksonn. 😄

@jorgebucaran jorgebucaran unpinned this issue Apr 3, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
discussion docs You read it here first
Projects
None yet
Development

No branches or pull requests

5 participants