Cycle style apps using Laminar on ScalaJS
Artifact:
com.github.vic.laminar_cycle::cycle-core::VERSION
Each release artifacts are available from JitPack
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.
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;
}
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.
The type CIO[I, O]
stands for CycleIO
and models inputs of type I
and outputs of type O
.
As an example of Inputs and Outputs, suppose you need to interact with
some external API by sending it Request
s and receiving Response
s 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.
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 forCIO[E, E]
.EIO stands for Equal IO, meaning that both, input and output have the same type
E
. It's equivalent to Airstream'sEventBus[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
andstates
. -
The return type is
Mod[Element]
.The
updatedState --> states
produces Laminar modifier that manages the event subscriptions. From now on we will be using thetype 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.
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))
)
}
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.
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.
./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.