Cancellation

This example shows how long running effects can be made cancellable, either by triggering the same effect again before it has completed, or explicitly by, say, clicking a button. We illustrate this with a simple HTTP GET request to The Bored API.

State

The state holds the most recently fetched activity and the loading state, which indicates whether a request is currently running.

case class State(
    activity: Option[Activity] = None,
    loading: Boolean = false
)

The JSON API response is modeled using a case class with a circe decoder instance.

import io.circe.*
import io.circe.generic.semiauto.*

case class Activity(activity: String)

object Activity:
  given Decoder[Activity] = deriveDecoder

Actions

enum Action:
  case Cancel
  case GetRandomActivity
  case SetActivity(activity: Option[Activity])
  case SetLoading(loading: Boolean)

Store

The interesting bit is the construction of the store. By wrapping the data fetching effect with store.withCancellationKey, we can cancel it using store.cancel. By wrapping it with store.withRunningState, we can observe whether it is running using store.runningState. Finally, we keep the loading state in sync by subscribing to changes of store.runningState.

import cats.effect.*
import cats.effect.implicits.*
import cats.syntax.all.*
import scala.concurrent.duration.*

object Store:

  private val cancelKey = "activity"
  private val loadingKey = "loading"

  def apply[F[_]](using
      F: Async[F]
  ): Resource[F, ff4s.Store[F, State, Action]] =
    for
      store <- ff4s.Store[F, State, Action](State()): store =>
        case (Action.SetActivity(activity), state) =>
          state.copy(activity = activity) -> F.unit
        case (Action.SetLoading(loading), state) =>
          state.copy(loading = loading) -> F.unit
        case (Action.Cancel, state) => state -> store.cancel(cancelKey)
        case (Action.GetRandomActivity, state) =>
          (
            state.copy(activity = none),
            store
              .withCancellationKey(cancelKey)(
                store.withRunningState(loadingKey)(
                  F.sleep(
                    1.second // pretend that this is really long running
                  ) *>
                    ff4s
                      .HttpClient[F]
                      .get[Activity]("https://www.boredapi.com/api/activity")
                      .flatMap(activity =>
                        store.dispatch(Action.SetActivity(activity.some))
                      )
                )
              )
          )

      _ <- store
        .runningState(loadingKey)
        .discrete
        .evalMap(loading => store.dispatch(Action.SetLoading(loading)))
        .compile
        .drain
        .background
    yield store

View

trait View:
  self: ff4s.Dsl[State, Action] =>

  val view =

    import html._

    useState: state =>
      div(
        button(
          "Get activity",
          onClick := (_ => Action.GetRandomActivity.some)
        ),
        button("Cancel", onClick := (_ => Action.Cancel.some)),
        if (state.loading) div("loading...")
        else div(s"${state.activity.map(_.activity).getOrElse("")}")
      )