Shironeko is a very unopinionated library, providing you with multiple options for building your application. In particular, you can use tagless style, where state container is parameterized with an effect type, or direct style with a concrete effect type. You can also use event-sourcing and hide direct state manipulation, or expose state cells directly in algebra. You can put actions into state container itself, or write them outside in a separate class/object, make some parts private and expose only minimum needed to render the app. You can also use multiple algebras for different state parts.

If you are not sure where to even begin, I’ll make the choices for you:

  • If you’re using cats-effect IO, go with tagless style. If you’re using monix or ZIO, go with direct style.
  • Start with one algebra and one connector.
  • Start with direct state manipulation and exposed state cells. Avoid event sourcing as much as possible. Don’t encapsulate anything, but put related state pieces into a single cell as a case class.
  • Put all actions into separate objects/classes. Don’t write them inline and don’t put them together with app state.

Tagless or non-tagless

Consider tagless style if you’re familiar with technique, want to exercise more discipline or use cats.effect.IO, there is little benefit to this concrete type.

Use direct style if you’re using capabilities of a more powerful effect type. For example, monix Task has Parallel, Timer and ContextShift always available in scope, allowing you to .debounce any Stream and use parMapN-like operators directly.

The connector definition is not different, and you still get a Concurrent instance and an implicit of your choice propagated - however, in direct style, the implicit must not be a type constructor.

There are also no ContainerF base classes, as they are not necessary.

Finally, there is no restriction on exec/toCallback method usage. And, since you know which effect you’re using, you might use its methods (like unsafeRunAsyncAndForget on IO) directly if the type allows it.

Other than that, the API/usage is very similar in two styles:

// Algebras
class AlgebraF[F[_]](dsl: StoreDSL[F]) {
  import dsl._
  val state = cell(42)
  val log   = cell(List.empty[String])
}

class Algebra(dsl: StoreDSL[Task]) {
  import dsl._
  val state = cell(42)
  val log   = cell(List.empty[String])
}

// Connectors
object Connector1 extends TaglessConnector[AlgebraF]
object Connector2 extends DirectConnector[Task, Algebra]

// Containers

object TestContainer extends Connector1.ContainerNoProps {
  case class State(i: Int, l: List[String])
  def subscribe[F[_]: Subscribe]: fs2.Stream[F, State] = {
    val F = getAlgebra
    combine[State].from(
      F.state.discrete,
      F.log.discrete
    )
  }
  
  def render[F[_]: Render](s: State) = 
    div(
      div(state.i, onClick := toCallback { getAlgebra.log.update("Clicked" :: _) }),
      div(state.l.mkString(", "))
    )
}

object TestContainer extends Connector2.ContainerNoProps {
  case class State(i: Int, l: List[String])
  def subscribe: fs2.Stream[Task, State] = {
    val F = getAlgebra
    combine[State].from(
      F.state.discrete,
      F.log.discrete
    )
  }
  
  def render(s: State) = 
    div(
      div(state.i, onClick := toCallback { getAlgebra.log.update("Clicked" :: _) }),
      div(state.l.mkString(", "))
    )
}

Event sourcing vs direct modification

I recommend not using event sourcing as a method for single state changes. You can always change multiple values in one action:

def updateCounter[F[_]: Monad](implicit F: Algebra[F]) =
  F.counter.update(_ + 1) >> F.updated.set(true)

The power of event streams comes from its async control flow features, allowing for fairly complex, but very manual interaction between multiple semantically independent actions.

Consider this simple example:

// Regular action
val renderer: F[Unit] = for {
  _    <- F.message.set(s"Starting a data request")
  id   <- F.ids.modify(x => (x + 1, x + 1))
  _    <- requestor(id).start
  _    <- F.events.await1 { case Accepted(`id`) => () }
  _    <- F.message.set(s"Request $id was accepted")
  data <- F.events.await1 { case Completed(`id`, data) => data }
  _    <- F.message.set(s"Request $id completed, god $data")
} yield ()

def requestor(id: Int): F[Unit] = for {
  _    <- backend.put(s"/request/$id")
  _    <- F.events.emit1(Accepted(`id`))
  data <- backend.get(s"/request/$id/result")
  _    <- F.events.emit1(Completed(id, data))
} yield () 
  

// Set this one up in the `main`
val logger = F.events.onNextDo {
  case Completed(id, data) => F.log.update(s"Request $id: $data" +: _)
  case _ => ().pure[F]
}

While powerful and convenient, it lacks proper error handling, and an error in requestor will cause the continuation of renderer to hang forever. Using return values (F[A]) where possible could solve that:

val renderer: F[Unit] = for {
  _    <- F.message.set(s"Starting a data request")
  id   <- F.ids.modify(x => (x + 1, x + 1))
  data <- requestor(id)
  _    <- F.message.set(s"Request $id completed, god $data")
} yield ()

def requestor(id: Int): F[Data] = for {
  _    <- backend.put(s"/request/$id")
  _    <- F.message.set(s"Request $id was accepted")
  data <- backend.get(s"/request/$id/result")
  _    <- F.events.emit1 { Completed(id, data) }
} yield data
  

// This one is okay, it's not waiting forever.
val logger = F.events.onNextDo {
  case Completed(id, data) => F.log.update(s"Request $id: $data" +: _)
  case _ => ().pure[F]
}