Shironeko
A cat that can manage state
Shironeko is a state management library for Scala.js with the following goals:
- Make simple things trivial and hard things possible without boilerplate
- Support writing logic in pure FP, with cats-effect and final tagless style
- Be developer and IDE-friendly
Currently only supports Scala 2.12
Dependencies
libraryDependencies += "com.olegpy" %%% "shironeko-core" % "0.1.0-M1"
libraryDependencies += "com.olegpy" %%% "shironeko-slinky" % "0.1.0-M1"
Core abstractions
shironeko is largely relying on cats-effect and fs2. Every action that happens is represented by F[Unit]
(for cats-effect compatible effect type F
). All data that is rendered comes in fs2.Stream
. For state cells that can be changed manually, SignallingRef[F, A]
is used, as it provides a stream of changed values via .discrete
method.
Store
is a class containing the data your application is showingContainer
is a (react) component which is able to show data from the Store and has FFI compatibilities for react interopConnector
is an object which links Containers to an instance of a Store
Example
Basics
Let's say we'll be using tagless final style. We want to create a simple counter which you can increment or decrement, so we keep that state in a store:
class Store[F[_]](val counter: SignallingRef[F, Int])
object Store {
def make[F[_]: Concurrent] = SignallingRef[F, Int](0).map(new Store[F](_))
}
To get any updates, we need to first create a Connector
for our application.
object Connector extends SlinkyConnector[Store]
Connectors define a number of base classes to be extended by other singleton objects. Here, let's use a simple container without any props:
object CounterDisplay extends Connector.ContainerNoProps {
override type State = Int
override def subscribe[F[_]: Subscribe] = getAlgebra.counter.discrete
override def render[F[_]: Render](state: State) = {
div(
button(onClick := toCallback { getAlgebra.counter.modify(_ - 1) })("-"),
s"Current value is $state",
button(onClick := toCallback { getAlgebra.counter.modify(_ + 1) })("+"),
)
}
}
Extending any container class gives access to the:
- Store instance (
getAlgebra
) forF
effect type in subscribe and render - Concurrent instance (
getConcurrent
) forF
effect type in subscribe and render - FFI type
Exec
(getExec
) forF
in render only, for tagless style, or in both render and subscribe when using concrete effect type.
Exec
allows you to use exec(fa)
to schedule fa
for later execution, and also toCallback
utility, converting fa
to impure callback (() => Unit
)
With this, we have enough to build our app. I will be using cats.effect.IO
as effect type, and the easiest way to get all needed typeclass instances is by extending IOApp
:
object Main extends IOApp {
override def run(args: List[String]) = {
Store.make[IO].flatMap(store => IO.suspend {
val root = dom.document.getElementById("root")
ReactDOM.render(root, Connector(store)(CounterDisplay()))
IO.never.widen[ExitCode]
})
}
@JSExportTopLevel("main")
def main(): Unit = super.main(Array())
}
More complex states
It's quite rare that you can get away with just one SignallingRef
. For this example, let's save the number of times counter has been altered in a separate SignallingRef:
class Store[F[_]](
val counter: SignallingRef[F, Int],
val changes: SignallingRef[F, Int],
)
object Store {
def make[F[_]: Concurrent] =
(SignallingRef[F, Int](0), SignallingRef[F, Int](0)).mapN(new Store[F](_, _))
}
Store DSL
Given how unwieldy these constructors can grow, shironeko has a DSL that you can use to create the store more declaratively:
class Store[F[_]](dsl: StoreDSL[F]) {
import dsl._
val counter = cell(0)
val changes = cell(0)
}
object Store {
def make[F[_]: Concurrent] =
StoreDSL[F].use(new Store[F](_).pure[F])
}
StoreDSL
is a Resource that cannot be used after the constructor has been executed. Its methods bypass referential transparency to create signalling refs immediately. Because of this, you must use val
, not lazy val
or def
and also you cannot store the dsl
somewhere for other state allocation (it'll crash).
Also, if you use DSL methods in object
s defined inside your store, beware that objects are initialized lazily, when first demanded.
Writing actions
You don't have to put every state update inline into the rendered component. When logic grows reasonably complex, you can write them anywhere - just remember that store and Concurrent
instance are given for you in the implicit scope in the body of render
. For example, you can write:
object CounterActions {
def increment[F[_]: Monad](implicit S: Store[F]): F[Unit] =
S.counter.modify(_ + 1) >> S.changes.modify(_ + 1)
def decrement[F[_]: Monad](implicit S: Store[F]): F[Unit] =
S.counter.modify(_ - 1) >> S.changes.modify(_ + 1)
}
And, since it's just plain effect datatypes, there's zero reason why we can't just factor out repeating parts:
object CounterActions {
private[this] def change[F[_]: Monad](by: Int)(implicit S: Store): F[Unit] =
S.counter.modify(_ + by) >> S.changes.modify(_ + 1)
def increment[F[_]: Monad](implicit S: Store[F]): F[Unit] =
change[F](1)
def decrement[F[_]: Monad](implicit S: Store[F]): F[Unit] =
change[F](-1)
}
You may delete increment/decrement and use change
directly. Your call.
Combining multiple cells
Let's revisit our display component. We need to show multiple values at the same time. We can't flatMap
two calls to discrete
- that gives us an endless stream of pairs of all values ever came through our app. What is needed is parallel combination - take the latest value that has arrived in each SignallingRef
, and emit these pairs of latest values.
Shironeko provides a blackbox macro util.combine
that allows you to construct a stream of case class instances out of several streams, one per each field. It also works for tuples, if you don't like nicely named fields.
The construct is combine[A].from(stream1, stream2, ...)
. A concrete type A
needs to always be specified, as it guides macro inference.
object CounterDisplay extends Connector.ContainerNoProps {
case class State(value: Int, changed: Int)
override def subscribe[F[_]: Subscribe]: Stream[F, State] = {
val S = getAlgebra
combine[State].from(
S.counter.discrete,
S.changes.discrete
)
}
override def render[F[_]: Render](state: State) = {
div(
button(onClick := toCallback { CounterActions.decrement[F] })("-"),
s"Current value is ${state.value}, changed ${state.changed} times",
button(onClick := toCallback { CounterActions.increment[F] })("+"),
)
}
}
Using event-based model
TODO