OVO Tech Blog
OVO Tech Blog

Our journey navigating the technosphere

Tom Verran
Author

Since joining OVO in 2017 I've worked on the Propositions Team, bringing you OVO Beyond.

Share


Tags


FS2 streams with Scala JS

I’ve recently been writing a small Scala JS frontend application to provide an administration interface to an API for a side project and have had a good experience doing so, owing to the surprisingly (to me, at least!) comprehensive support for Scala JS amongst cats, cats-effect and fs2. I've found that using these libraries along with scalatags has been a good learning experience and I thought I'd share my discoveries here.

I'm using the following dependencies throughout the code examples below:

Seq(
  "org.typelevel"   %%% "cats-effect"   % "1.1.0",
  "org.typelevel"   %%% "cats-core"     % "1.5.0",
  "com.lihaoyi"     %%% "scalatags"     % "0.6.7",
  "org.scala-js"    %%% "scalajs-dom"   % "0.9.2",
  "co.fs2"          %%% "fs2-core"      % "1.0.2"
)

ScalaJS' DOM library itself sticks closely to the native JavaScript DOM API which can make it difficult to write idiomatic applications using cats-effect but with the help of fs2 it is relatively easy to represent DOM events as streams through the use of a queue to capture events from ScalaJS event handlers (classic Event => Unit callbacks):

/**
  * Add a button to the DOM and
  * set up a stream of click events from it
  */
def button[F[_]](
  to: Element,
  caption: String
)(implicit F: ConcurrentEffect[F]): Stream[F, Event] =
  Stream.eval(
    Queue.circularBuffer[F, Event](maxSize = 10).flatTap { q =>
      F.delay {
        val newElement = input(
          `type` := "button",
          `value` := caption
        ).render
        newElement.addEventListener(
          `type` = "click",
          listener = (e: Event) => F.toIO(q.enqueue1(e)).unsafeRunAsyncAndForget
        )
        to.appendChild(newElement)
      }
    }
  ).flatMap(_.dequeue)

Another way fs2 can help is with Stream.bracket - a function that assists in managing state by providing the means to acquire a resource for use in a stream before having it automatically cleaned up when the stream terminates. I’ve found this means it is practical to model UI components in frontend applications as fs2 streams - Stream.bracket can be used to add the DOM nodes for the component to the page during the acquire step before providing events from the nodes as a stream. The previously added nodes will then be removed from the page when the event stream is terminated:

  /**
    * Make use of `Stream.bracket` to add an element to the DOM
    * which will be automatically cleaned up when the stream 
    * of events is terminated
    */
  def button[F[_]](
    to: Element,
    caption: String
  )(implicit F: ConcurrentEffect[F]): Stream[F, Event] = {

    def addElement(q: Queue[F, Event]): F[Node] = F.delay {
      val newElement = input(
        `type` := "button",
        `value` := caption
      ).render
      newElement.addEventListener(
        `type` = "click",
        listener = (e: Event) => F.toIO(q.enqueue1(e)).unsafeRunAsyncAndForget
      )
      to.appendChild(newElement)
    }

    def removeElement(element: Node): F[Unit] =
      F.delay(to.removeChild(element))

    Stream.eval(Queue.circularBuffer[F, Event](maxSize = 10))
      .flatMap { queue =>
        Stream.bracket(addElement(queue))(removeElement)
          .flatMap(_ => queue.dequeue)
      }
  }

Finally fs2 offers a number of stateful stream transforms, such as mapAccumulate which can be used to manipulate state based on events fired by the DOM. Here's an example of a tiny app that displays how often a button is clicked in another button, which itself is removed by terminating its stream when it is clicked:

def run(args: List[String]): IO[ExitCode] =

  // add a button to the DOM
  button[IO](body, "Click me")

    // keep track of how often it is clicked
    // and emit only that number downstream
    .zipWithIndex.map(_._2)

    // add a button that will be removed after one click.
    // using switchMap means this stream will be interrupted
    // if the first button is clicked, so the count button will update
    .switchMap(ts => button(body, s"Clicked $ts times").take(1))
    .compile.drain.as(ExitCode.Success)

scalajs-demo

The boilerplate involved in bridging the divide between DOM functions and streams is a definite disadvantage of this approach but I think once it is written the resulting application code can end up being quite reasonable, though I've really only just begun exploring this way of writing apps.

One final disadvantage of ScalaJS I've begun to hit against is the relative lack of facades for popular JavaScript libraries. There is, however a useful list and I think that if you're looking for some easy open source contributions that writing some facades is a good place to start!

Tom Verran
Author

Tom Verran

Since joining OVO in 2017 I've worked on the Propositions Team, bringing you OVO Beyond.

View Comments