Data Fetching
Fetching data from the back-end is probably the most common type of IO in single-page applications. In this example we illustrate this pattern using a simple HTTP GET request to the Frankfurter API, which provides foreign exchange rates published by the European Central Bank.
State
The state needs to hold the user's input, the exchange rate returned by the API, and possibly an error message in case something goes wrong. We also add a convenience method for parsing currency pairs from user input.
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
As per usual, we model the JSON API response using a case class with a derived circe decoder:
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
case SetApiResponse(response: Option[ApiResponse])
case SetErrorMessage(msg: Option[String])
case SetUserInput(input: Option[String])
Store
The interesting bit is the handling of MakeApiRequest
.
Note how we retrieve the currency pair from the state and how we
are updating the state with the response using store.dispatch
.
import cats.effect.*
import cats.syntax.all.*
object Store:
def apply[F[_]](using
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(input), state) =>
state.copy(userInput = input) -> F.unit
case (Action.SetErrorMessage(msg), state) =>
state.copy(errorMessage = msg) -> F.unit
case (Action.MakeApiRequest, state) =>
(
state,
state.ccyPairOption.foldMapM: (ccy1, ccy2) =>
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)
)
)
)
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) =>
// casting is sometimes necessary when `org.scalajs.dom` doesn't give us precise enough types.
val target = ev.target.asInstanceOf[dom.HTMLInputElement]
Action.SetUserInput(target.value.some).some
)
),
button(
"Get FX Rate",
onClick := (_ => Action.MakeApiRequest.some),
disabled := state.ccyPairOption.isEmpty
),
state.errorMessage match
case Some(errorMsg) => div(styleAttr := "color: red", errorMsg)
case None =>
div(
s"${state.apiResponse.flatMap(_.rates.values.toList.headOption).getOrElse("")}"
)
)
App
The boilerplate for ff4s.App
and ff4s.IOEntryPoint
is omitted.