Subscriptions
The fact that the store is a Resource
and that store.state
is a Signal
allows us to subscribe to updates of (part of) the state and react to them.
A common use case is debouncing API calls based on user input.
State
case class State(
userInput: Option[String] = None,
apiResponse: Option[ApiResponse] = None,
errorMessage: Option[String] = None
):
def ccyPairOption: Option[(String, String)] =
userInput.flatMap:
case ccyPairPattern(ccy1, ccy2) => Some((ccy1, ccy2))
case _ => None
lazy val ccyPairPattern = """([a-zA-Z]{3})/?([a-zA-Z]{3})""".r
import io.circe.*
import io.circe.generic.semiauto.*
case class ApiResponse(rates: Map[String, Double])
object ApiResponse:
given Decoder[ApiResponse] = deriveDecoder
Actions
enum Action:
case MakeApiRequest(ccy1: String, ccy2: String)
case SetApiResponse(response: Option[ApiResponse])
case SetErrorMessage(msg: Option[String])
case SetUserInput(userInput: Option[String])
Store
import cats.effect.*
import cats.effect.implicits.*
import scala.concurrent.duration.*
import cats.syntax.all.*
object Store:
def apply[F[_]](implicit
F: Async[F]
): Resource[F, ff4s.Store[F, State, Action]] =
ff4s
.Store[F, State, Action](State()): store =>
case (Action.SetApiResponse(response), state) =>
state.copy(apiResponse = response, errorMessage = None) -> F.unit
case (Action.SetUserInput(userInput), state) =>
state.copy(userInput = userInput) -> F.unit
case (Action.SetErrorMessage(msg), state) =>
state.copy(errorMessage = msg) -> F.unit
case (Action.MakeApiRequest(ccy1: String, ccy2: String), state) =>
(
state,
ff4s
.HttpClient[F]
.get[ApiResponse](
s"https://api.frankfurter.app/latest?from=$ccy1&to=$ccy2"
)
.flatMap(response =>
store.dispatch(Action.SetApiResponse(response.some))
)
.handleErrorWith(t =>
store.dispatch(
Action.SetErrorMessage(s"Failed to get FX rate: $t".some)
)
)
)
.flatTap:
// subscribe to changes in user input and trigger debounced API calls
store =>
store.state
.map(_.ccyPairOption)
.discrete
.changes
.debounce(1.second)
.evalMap:
case Some((ccy1, ccy2)) =>
store.dispatch(Action.MakeApiRequest(ccy1, ccy2))
case None => store.dispatch(Action.SetApiResponse(None))
.compile
.drain
.background
View
trait View:
self: ff4s.Dsl[State, Action] =>
import html.*
import org.scalajs.dom
val view =
useState(state =>
div(
input(
tpe := "text",
placeholder := "e.g. EUR/USD or EURUSD",
onInput := ((ev: dom.Event) =>
val target = ev.target.asInstanceOf[dom.HTMLInputElement]
Action.SetUserInput(target.value.some).some
)
),
state.errorMessage match
case Some(errorMsg) => div(styleAttr := "color: red", errorMsg)
case None =>
div(
s"${state.apiResponse.flatMap(_.rates.values.toList.headOption).getOrElse("")}"
)
)
)
App
Implementation of ff4s.App
and ff4s.IOEntryPoint
is straightforward and omitted.