@abradley2/frpuccino

A lightweight reactive UI library built on @most/core

Usage no npm install needed!

<script type="module">
  import abradley2Frpuccino from 'https://cdn.skypack.dev/@abradley2/frpuccino';
</script>

README

FRPuccinno

:warning: This is still in alpha development. I would love to get more people testing this out and finding issues to be stamped out, but I wouldn't recommend using this on any big projects that require a reliable view layer just yet! :warning:

FRPuccino is a small UI library built on the foundation of Most.js

It's inspired heavily by Elm and Cycle.js

Here's a short "counter" example:

import { createElement, createApplication } from '@abradley2/frpuccino'

function update (model, addValue) {
  return model + addValue
}

function view () {
  return <div>
    <button onclick={1}>Clicked {value} times!</button>
  </div>
}

createApplication({
  mount: document.getElementById('app'),
  update,
  view,
  init: 0
}).run(0)

Installation

@most/core and a few other associated libraries are peer dependencies.

npm install --save @most/core @most/scheduler @most/types @abradley2/frpuccino

You will also likely find @most/dom-event and @most/adaptor very useful. But only add them as you find the need. I do recommend checking out the README of each so you can recognize when that need arises.

Core Concepts

You do not actually need to be familiar with FRP to make use of FRPuccino. Reactive Programming is more the underlying engine of the API than the API itself. It will be very helpful, however, if you are familiar with the concepts of Streams and Sinks.

API Usage and Tutorial

Only two methods are needed to create basic applications: createElement and createApplication

Function: createElement

createElement is FRPuccino's "React.createElement" drop-in replacement.

This createElement is unique. It returns an un-mounted DOM element, with a Stream consisting of all bound event handlers.

Here's a short illustrative example

/** @jsx createElement */
import { createElement } from '@abradley2/frpuccino'
import { newDefaultScheduler } from '@most/scheduler'

const el = <div>
  <button onclick='Hello there'>Say Hello</button>
  <button onclick='Farewell for now!'>Say Goodbye</button>
</div>

document.body.appendChild(el)

const sink = {
  event: (t, message) => { alert(message) },
  end: () => {},
  error: () => {}
}

el.eventStream.run(sink, newDefaultScheduler())

Our el above is a simple Element that we can append to our document. But createElement also casts all the registered event handlers on that Element and all it's children to a single eventStream (or Stream<Action>) at the top node.

We can run this stream similar to how we'd run any regular @most/core stream

Because all our event handlers are bound to strings the type of the event stream is Stream<string>.

Function: createApplication

createElement is cool, but by itself we can't really create complicated user interfaces. createApplication is a way of "looping" the eventStream returned by createElement into an update function.

This cycle of update -> event -> createElement -> update forms the basic flow of all applications.

createApplication({
  // this is our initial update value!
  init: 0,

  // this is what our user interface looks
  view: (currentState) =>(<div>
    <button onclick={1}>Clicked {currentState} times</button>
  </div>),

  update: (currentState, value) => currentState + value,

  // we need a place to attach our view to the document
  mount: document.getElementById('application')
})
  // to kick off the stream we need to emit an initial value.
  // here we emit "0" because we won't want to increment our state
  // until the user actually clicks the button
  .run(0)

Type: TaskCreator<Action>

Our examples up until now have only dealt with event handlers bound in our view. What if we want to execute and respond to an HTTP request? What if we want to listen to an event that is scoped outside of our view (such as window.onscroll)? We can use Tasks for this.

Tasks are helper functions that allow us to propagate events to Sinks

When we call createApplication an internal Sink is created which does a couple things. It subscribes to the Stream of events returned by our view and update functions, and the resulting DOM nodes created by composing update with view. The definition is similar to

Sink<{eventStream: Stream<{action: Action}>, view?: Element}>

A "Task Creator" that creates a Scheduled Task to propagate events to this Sink will look something like this:

import { TaskCreator } from '@abradley2/frpuccino'
import { now, propagateEventTask } from '@most/core'
import { asap } from '@most/scheduler'

export function propagateEvent <Action> (action: Action): TaskCreator<Action> {
  const event = { eventStream: now({ action }) }

  return (sink, scheduler) => {
    const task = propagateEventTask(event, sink)

    return asap(task, scheduler)
  }
}

Type: UpdateResult<Model, Action>

The main update function is allowed to return more than just the next version of the Model. It may also return an array consiting of the model as the first item, and either TaskCreator<Action> or TaskCreator<Action>[] as the second item.

We can change our original update function so when our application starts, we use our propagateEvent function to start us out with counter incrementing once by a value of "1"

function update (currentState, value) {
  // recall that we specified "0" as our initial action to dispatch when
  // our application starts.
  if (value === 0) {
    return [currentState, propagateEvent(1)]
  }

  return currentState + value
}

UpdateResult<Model, Action> is very flexible. We can not only give a single scheduled task to be executed as a result of update, but many.

if (value === 0) {
  return [
    currentState,
    [
      propagateEvent(1),
      propagateEvent(1)
    ]
  ]
}

Method: mapElement

Similar to how we can map a Stream from Stream<A> -> Stream<B> we can use mapElement to convert the eventStream of one element to another Stream type. This is useful to avoid cases where due to composing many different modular features we end up with types like Stream<A | B | C | D | E> that are difficult to reason about in our application's update function.

Here's an illustrative example of avoiding an update function having to deal with an event of type number | string by normalizing all eventStreams to Stream<numer>

function button () {
  return <div>
    <button onclick={1}>Click me</button>
  </div>
}

function input () {
  return <div>
    <input onchange={(e) => e.target.value} />
  </div>
}

function application () {
  return <div>
    <div>
      Count by one:
      {button()}
    </div>
    <div>
      Count by input:
      {mapElement(
        (payload) => {
          if (!Number.isNaN(result)) return result
          return 0
        },
        input()
      )}
    </div>
  </div>
}

:warning: Due to performance reasons, mapElement actually mutates the Element passed to it. Always use a constructor function to pass the second argument to this function :warning: