/laminar_cycle

A cycle.js style user-computer model in Laminar

Primary LanguageScalaApache License 2.0Apache-2.0

Laminar.cycle

Main workflow Jipack

Everything is a Stream

Cycle style apps using Laminar on ScalaJS

Installation

Artifact: com.github.vic.laminar_cycle::cycle-core::VERSION

Each release artifacts are available from JitPack

Intro

Laminar is an awesome tool for creating Functional Reactive interfaces enterely based on Streams.

This repository offers a tiny library built on Laminar that can help you build applications using Cycle's dialogue abstraction.

cycle API

Senses and Actuators

In the Cycle's dialogue abstraction pictured above, both the Human and the Computer can be seen as entities interacting with each other by means of Senses to Actuators.

These Input and Output devices drive the interaction between actors.

This way, the Computer reacts to user interactions (like clicks) by producing an updated interface, and the User reacts to the interface on screen they see by operating on it (clicking again).

In Cycle.js, every component and the whole Computer can be seen like a function from input streams to output streams.

// Javascript
function computer(inputDevices) {
  // define the behavior of `outputDevices` somehow
  return outputDevices;
}

Laminar.cycle

Since Laminar is already powerful enough to efficiently create and render stream-based reactive html elements, all we need now is a function to model the previously seen cycle dialogue abstraction.

CycleIO

The type CIO[I, O] stands for CycleIO and models inputs of type I and outputs of type O.

And yes, it's also a nod to the ZIO data type ;D -- @vic

As an example of Inputs and Outputs, suppose you need to interact with some external API by sending it Requests and receiving Responses from it.

Note: once you finish reading this guide, you might want to look at the SWAPIDriver example to see how to implement a Cycle driver in Laminar.

object ExternalAPI {
  sealed trait Request
  sealed trait Response
}

In the following code snippet, we have a computer function that can take stimulus (Response) from the API but also might produce stimulus for it (Request).

import cycle._
import com.raquo.laminar.api.L._
import ExternalAPI.{Request, Response}

def computer(api: CIO[Response, Request]) = {
  // TODO: send requests and receive responses from API
}

You can think of the CIO[Input, Output] type as the following trait.

If you want to know the truth, use the source, Luke. You will see it's actually defined as type alias in Cycle.scala -- @vic

trait CIO[I, O] {
  val in:  EventStream[I]
  val out: WriteBus[O]
}

it provides an incoming Observable[I] stream and an outgoing Observer[O] write bus.

Pretty much similar to Airstream's own EventBus[T] but generic on both input and output types.

Combining state and user interaction.

Suppose we want to implement a counter UI that is able to track the current counter value and the number of times the user has interacted with the counter. A working example can be found at Examples

object Counter {

  case class State(
    value: Int,
    numberOfInteractions: Int
  )
  
  sealed trait Action // Type of the sensed stimulus produced by the user
  case object Increment extends Action
  case object Decrement extends Action

}

Having the above types, we could define our computer function like:

import Counter._

def computer(states: EIO[State], actions: EIO[Action]): Mod[Element] = {
  // Initialize the computer's internal state and keep it on a Signal[State]
  // in order to always have a *current value*
  val stateSignal: Signal[State] = states.startWith(State(0, 0))
  
  // Now, whenever we sense a user Action, we have to update our current state
  val updatedState: EventStream[State] = 
     actions.withCurrentValueOf(stateSignal).map(Function.tupled(performAction))
 
  updatedState --> states
}

def performAction(action: Action, state: State): State = action match {
 case Increment =>
   state.copy(state.value + 1, state.numberOfInteractions + 1)
 case Decrement =>
   state.copy(state.value - 1, state.numberOfInteractions + 1)
}

Let's explore our previous example code.

  • The EIO[E] type is just an alias for CIO[E, E].

    EIO stands for Equal IO, meaning that both, input and output have the same type E. It's equivalent to Airstream's EventBus[E]

  • Our previous example does not render anything (we will get to producing views later).

    Yet, it's a fully working example of how to create a cycle function that reads and writes from both: actions and states.

  • The return type is Mod[Element].

    The updatedState --> states produces Laminar modifier that manages the event subscriptions. From now on we will be using the type ModEl = Mod[Element] alias included with this lib.

    Read more about modifiers and subscription ownership at LaminarDocs. All of this is part of Laminar's memory safety and glitch free guarantees.

Producing reactive views

Now, we will refactor our computer function to actually render a user interface.

For brevity sake, we will add ??? for previously seen code.

def computer(states: EIO[State], actions: EIO[Action]): Div = {
  val stateSignal: Signal[State] = ???
  val updatedState: EventStream[State] = ???
 
  div(
    counterView(stateSignal),
    actionControls(actions),
    updatedState --> state
  )
}

def actionControls(actions: Observer[Action]): Mod[Div] = {
  cycle.amend(
    button(
      cls := "btn secondary",
      "Increment",
      onClick.mapTo(Increment) --> actions
    ),
    button(
      cls := "btn secondary",
      "Decrement",
      onClick.mapTo(Decrement) --> actions
    ),
    button(
      cls := "btn secondary",
      "Reset",
      onClick.mapTo(Reset) --> actions
    )
  )
}

def counterView(state: Observable[State]): Div = {
  div(
    h2("Counter value: ", child.text <-- state.map(_.value.toString)),
    h2("Interactions: ", child.text <-- state.map(_.interactions.toString))
  )
}

Drivers

A Driver is the Cycle-way to interpret effects and interact with the outside world.

In Laminar, Drivers are couple of Devices and Binders.

Devices are things like CIO[I, O], EMO[T], etc, that provide read/write streams outside the Driver.

Binders are Laminar's Binder[Element] that provide a way to start and interrupt dynamic subscriptions in order to keep Laminar's safe-memory and glitch-free guarantees.

The following is the full source code for the DOM Fetch included in this library:

import com.raquo.laminar.api.L._
import org.scalajs.dom.experimental._

object fetch {
  final case class Request(input: RequestInfo, init: RequestInit = null)

  // The Devices as seen from this driver's user perspective.
  type FetchIO = CIO[(Request, Response), Request]

  def driver: Driver[FetchIO] = {
    val devices = PIO[Request, (Request, Response)]
    val reqRes = devices.flatMap(req =>
      EventStream.fromFuture {
        Fetch.fetch(req.input, req.init).toFuture
      }.map(req -> _)
    )
    Driver(devices, reqRes --> devices)
  }
}

When used inside an element, Drivers provide their IO devices to the block given to them and the binders set up dynamic subscriptions to start/stop when the parent element is mounted/unmounted from UI.

Available Drivers

All drivers artifact: com.github.vic.laminar_cycle::cycle::VERSION

Individual Drivers:

Artifact: com.github.vic.laminar_cycle::fetch-driver::VERSION

A cycle driver around DOM's Fetch API for executing HTTP requests.

Artifact: com.github.vic.laminar_cycle::state-driver::VERSION

This driver allows you to have State-layers. See the Onion example. This way sub-components can have an inward view, just a layer of the outer bigger state and their updates also get propagated outwards.

Artifact: com.github.vic.laminar_cycle::history-driver::VERSION

The DOM History driver allows you to push/replace and get an stream of current page state.

Artifact: com.github.vic.laminar_cycle::router-driver::VERSION

Stream based router driver. This driver does not depend directly but can be used with the History driver and anything that can encode/decode an URL like, for example urldsl.

Artifact: com.github.vic.laminar_cycle::mount-driver::VERSION

Convenience driver with event streams for mount and umount events.

Artifact: com.github.vic.laminar_cycle::tea-driver::VERSION

A simple driver that follows The Elm Architecture for updating a current state with both: Pure and Effectful actions.

Artifact: com.github.vic.laminar_cycle::topic-driver::VERSION

A Pub/Sub driver that allows any component in the system to communicate with each other by registering to their topic of interest.

You can use this to broadcast events across the whole application.

Artifact: com.github.vic.laminar_cycle::zio-driver::VERSION

Provides conversions from ZQueue to Laminar's EventStream and WriteBus. Automatically manages subscriptions between zio and Laminar streams.

Examples

./ci example cycle_counter

Runnable implementation of the counter example on README.md.

Shows basics of using cycle.InOut types, handling user actions to update the current state and update a view based on it.

./ci example onion_state

Shows basic usage of the State driver in an Onion-layered app.

./ci example elm_architecture

Sample using the The Elm Architecture to implement a sampler.

./ci example zio_clock

Effectful ZIO application that renders a Queue of Clock's nanoSeconds inside a Laminar view.

./ci example spa_router

This example uses the History and Router drivers and urldsl to implement a mock SPA (Single Page Application) social network.

To start the SPA, clone this repo and run:

./ci example swapi_driver

Search StartWars characters by name.

Shows how a Cycle driver looks like with Laminar.

The SWAPIDriver makes http requests to a the SWAPI database via REST.