If you don’t have a slinky project yet, I recommend to start from the official template:
$ sbt new shadaj/create-react-scala-app.g8
And add shironeko as a dependency:
libraryDependencies += "com.olegpy" %%% "shironeko-slinky" % "0.1.0-RC1"
This will transitively pull shironeko-core, cats-effect and fs2. You might want to specify concrete versions of those directly.
First, you need to describe an algebra containing any data that needs to be rendered on update. This algebra is similar in spirit to store in Redux or circuit in Diode.
For the sake of example, let’s implement a simple component that will update itself periodically:
import com.olegpy.shironeko._
import cats.implicits._
import cats.effect.Concurrent
class Algebra[F[_]](val count: Cell[F, Int])
object Algebra {
def create[F[_]: Concurrent] = Cell[F, Int](0).map(new Algebra(_))
}
Cell
here is merely an alias for fs2.concurrent.SignallingRef
.
Our next step is to create a React component that would render that
state. To do it, we will need something called a Connector
. Connector
links the algebra to a set of components that are going to use it.
object Connector extends TaglessConnector[Algebra]
Connector
contains several base classes for components that link
algebra to React elements.
import slinky.web.html._
object CurrentCount extends Connector.ContainerNoProps {
type State = Int
def subscribe[F[_]: Subscribe] = getAlgebra.count.discrete
def render[F[_]: Render](state: Int) =
div(s"Current count: $state")
}
To actually link components with algebra, you need a ConcurrentEffect
instance where you do it. To see that it actually refreshes, let’s modify
the counter every 3 seconds with a simple monadic loop:
import scala.scalajs.js.annotation.JSExportTopLevel
import slinky.web.ReactDOM
import cats.effect.{IO, IOApp}
import org.scalajs.dom.document
import scala.concurrent.duration._
object TestCurrent extends IOApp {
def findNode = IO { document.getElementById("root") }
def updateCount(alg: Algebra[IO]) = {
val nap = IO.sleep(3.seconds)
val operate = alg.count.set(-1) >> nap >> alg.count.set(1) >> nap
operate.foreverM
}
def run(args: List[String]) = {
for {
alg <- Algebra.create[IO]
_ <- alg.count.set(10)
node <- findNode
_ <- IO { ReactDOM.render(Connector(alg)(CurrentCount()), node) }
_ <- updateCount(alg).start
} yield ()
} >> IO.never // JS apps don't terminate normally
// You will probably need this for webpack runner to run the app
@JSExportTopLevel("main")
def main(): Unit = super.main(Array())
}
Assuming the project is properly set up with slinky-webpack, you should
be able to use dev
command to run a server and see your element being
updated every 3 seconds:
Ok, now for something more serious:
- Integrating cats-effect actions with React
- Showing data from several sources in one component
Let’s have
- a counter with +/- buttons
- a name input
- current time display
in one component and one algebra.
import cats.effect._
import com.olegpy.shironeko.StoreDSL
import scalajs.js.Date
import scala.concurrent.duration.DurationInt
class NamedCounter[F[_]: Concurrent: Timer](dsl: StoreDSL[F]) {
import dsl._
val counter = cell(0)
val name = cell("")
private[this] val getTimestamp = Sync[F].delay(new Date().toString)
val timeStream = fs2.Stream.awakeEvery[F](1.second).evalMap(_ => getTimestamp)
}
We’ll need a new connector for this algebra, too:
object NameCountConnector extends TaglessConnector[NamedCounter]
Changing state is done by performing an action. An action in shironeko
is simply a value of type F[Unit]
. This allows you to compose and
abstract over actions using familiar combinators from cats and
cats-effect.
While not necessary, it might be convenient to list actions in separate classes/objects. Let’s do this for counter-related actions but not for name-related ones.
object CounterActions {
private def change[F[_]](by: Int)(implicit F: NamedCounter[F]) =
F.counter.update(_ + by)
def increment[F[_]: NamedCounter] = change(+1)
def decrement[F[_]: NamedCounter] = change(-1)
}
To use these actions, we will need to convert them to plain slinky/react
callbacks. Render
typeclass gives you limited FFI abilities in form
of exec
method, which takes F[Unit]
and schedules it for later time,
and toCallback
method, that can convert actions or functions returning
them to impure callbacks that are needed for slinky.
Due to structure of shironeko containers, in tagless style FFI is not
available anywhere outside of render
method.
import com.olegpy.shironeko.util.combine
import scalajs.js.Dynamic.literal
object AdjustableCount extends NameCountConnector.ContainerNoProps {
// You can implement `type State` with a case class
case class State(timestamp: String, count: Int, name: String)
def subscribe[F[_]: Subscribe]: fs2.Stream[F, State] = {
val F = getAlgebra
// `combine` macro takes a name of class, which companion has apply
// method (e.g. tuple or case class) and builds a stream of values
// of this class, built from the most recent values of each stream
combine[State].from(
F.timeStream,
F.counter.discrete,
F.name.discrete
)
}
def render[F[_]: Render](s: State) = {
div(
div(s"Hello, ${if (s.name.isEmpty) "shironeko user" else s.name}!"),
div(s"Current time: ${s.timestamp}"),
div(
// Here, toCallback converts action to () => Unit
button(onClick := toCallback(CounterActions.decrement))("-"),
span(style := literal(padding = "0 16px"), s"Current count is ${s.count}"),
button(onClick := toCallback(CounterActions.increment))("+")
),
div(
"Your name is ",
// Here, we use `exec` manually to trigger side-effects later
input(`type` := "text", value := s.name, onChange := { e =>
val newName = e.target.value
exec(getAlgebra.name.set(newName))
}),
button(onClick := toCallback(getAlgebra.name.set("")))("Reset")
)
)
}
}
The main object doesn’t change much. Since StoreDSL
provides impure
methods, it’s constructor will return a Resource
that will disable
those methods once it has been used up
import slinky.web.ReactDOM
import cats.effect.{IO, IOApp}
import org.scalajs.dom.document
import scala.scalajs.js.annotation.JSExportTopLevel
object TestCurrent extends IOApp {
def run(args: List[String]) = {
for {
node <- IO { document.getElementById("root") }
alg <- StoreDSL[IO].use(dsl => IO.pure(new NamedCounter(dsl)))
_ <- IO { ReactDOM.render(NameCountConnector(alg)(AdjustableCount()), node) }
} yield ()
} >> IO.never
}
You can use containers inside other containers, as well as mix them with
regular Slinky components as you wish. The only restriction is that
Connector.apply
is called higher in rendering tree than any of its’
containers.