abstracting a react data fetcher in scala.js with cats, cats-effect

abstracting a react data fetcher in scala.js with cats, cats-effect

Problem

I put together a library for my small scala.js reasonreact-like facade scalajs-react that reflects the ReasonReact react component interface. When you use this facade to build web user interfaces you still need to fetch data. You have many choices for building a fetcher component including using an application state management library like redux/mobx or scala.js-based diode.

The reasonreact component API includes a “reducer” built into every component so let’s just use that capability since its builtin and simple. I’ve found that with the builtin reducer, the need for global application state management is greatly reduced. If we can use a solid effects library then we should be all set. react@next has suspense and lazy loading. Unfortunately, these are not available in scala.js and may never be efficient in scala.js because scala.js cannot be easily compiled into separate “modules” without incurring larger runtime and payload overheads.

Dan Abramov at facebook has indicated that the react roadmap includes more advanced rendering pipelines in react that allow you to fastpath or normalpath rendering. This helps with keeping a good looking UI while data fetching asynchronously. The content below reflects prior practice, apollo graphql clients and reasonreact fetch components. You can also watch this youtube video which provides a non-abstracted version of the fetcher in scala.js.

Not to spoil the surprise, you will find that cats and cats-effect involvement in asynchronous processing literally boils down to two lines. State management takes up most of the lines of code. cats/cats-effet is involved in running the effect and formulating the effect (request)–two lines.

The scalajs-react library linked above has the same name as jagolly’s lib and is different from slinky. I’m probably going to change the name before 1.0 so there is less confusion but I have not got around to that yet. The final version of the fetcher is located on github.

Its is also important to realize that while the form of fetcher/data loader component discussed below is useful, a highly interactive and complex view has much more dynamic data needs so the fetch component below may be good for a sub-view where the amount of dynamic interaction is more narrow.

First Pass Solution

We can use cats and cats-effect to manage effects both on the JVM and in the browser using scala.js. cats-effect and scala.js allows us to reuse these common, functional asynchronous primitives. Of course, javascript has an easy API for asynchronous effects but let’s assume we will use cats-effect. cats-effect has many, many different combinators that are convenient to compose effects together. With javascript, you would use various libraries as well as the bluebird promise to gain similar capabilities.

We also want to create components that are fairly generic to improve re-use and composability. If you look at a large number of javascript react components focused on data fetching, all of them make many assumptions about how data is fetched—too many assumptions to be useful in all situations especially for more production oriented applications. For example, we often need to have all browser fetches go through an application specific API to record the # fetch calls and provide logging. Many of the reactjs libraries fail to provide this flexibility. reasonreact libraries that do not abstract out the fetching will also fail us on flexibility. Can we do better?

In scala.js, we can assume, for the moment, that we are using cats and cats-effect. Our fetcher component may look like the following that abstracts out the specific effect itself F but still can use an effectful F to meet our objectives:

/** Load state passed to a child. Kept outside of Fetcher so avoid "crossing
 * streams." (mixing and matching FetchState and child)
 */
sealed trait FetchState[+T]

/** Load was successful, hold item. */
case class Success[T](item: T) extends FetchState[T]

/** Load resulted in an error. */
case class Error(message: Option[String] = None) extends FetchState[Nothing]

/** Loading still in progress. */
case object Fetching extends FetchState[Nothing]

/** Initial state until a fetch request is made. */
case object NotRequested extends FetchState[Nothing]

/**
  * Fetch data and render a child with a fetch status.  Child can process the
  * data and typically memoizes it if it transforms it e.g. sorts it or converts
  * the values. Fetch provides a generic `F` that must be an `Effect` so a
  * result can be "fetched." You can create the Fetcher and provide the fetch
  * "recipe" in `F` as a parameter or let the child initiate a fetch--you have a
  * choice. Allowing the child to initiate a "fetch" makes the API messy.
  */
class Fetcher[F[_]: Effect, T](Name: String) {

  case class State[T](loadState: FetchState[T] = NotRequested)

  sealed trait Action
  case class Fetched(items: T) extends Action
  case class FetchError(message: String) extends Action
  case class Request(fetch: F[T]) extends Action

  val c = reducerComponent[State[T], Action](Name)
  import c.ops._

  type FetchCallback = F[T] => Unit

  /** Provide data loading status to the child. */
  def apply(
    /** Callback when fetch state changes. Convenience thunk to initiate fetch. */
    cb: (FetchState[T], FetchCallback) => ReactNode,
    /** Initial fetch. */
    initialValue: Option[F[T]] = None
  ) =
    c.copy(new methods {
      val initialState = _ => State()
      didMount = js.defined(self => initialValue.foreach(f => self.send(Request(f))))
      val reducer = (action, state, gen) => {
        action match {
          case Request(f) =>
            gen.updateAndEffect(state.copy(loadState=Fetching)) {
              self => Effect[F].runAsync(f) {
                  case Right(item) => self.send(Fetched(item))
                case Left(t) => self.send(FetchError(t.getMessage()))
                }
                .unsafeRunSync
            }
          case FetchError(message) =>
            gen.update(state.copy(loadState = Error(Some(message))))
          case Fetched(item) =>
            gen.update(state.copy(loadState = Success(item)))
        }
      }
      val render = self =>
      cb(
        self.state.loadState,
        f => self.send(Request(f))
      )
    })
}

Using cats-effect, we constrain our F to be an Effect (typeclass) and run the F using a general purpose algorithm that does not assume the specific F. For example, we could use an asynchronous effect or switch to Alex’s monix effects library which has an API that harkens back to mobx in the javascript world. Because we have parameterized with F we could switch our effects library without changing the code above.

You may not have caught it, but you can either provide an input that we run on mount to provide the asynchronous result or the child can initiate a fetch when it mounts or re-initiate a fetch. The idea is that this component manages the fetch state regardless of where the request may originate from–above or below. Some people may balk at this as one could pass down only the load state and not allow the child to re-initiate a fetch but I found that for some views, its convenient to have the ability for the child to initiate a fetch because the fetch may be dependent on state only relevant to the child but the fetch may need to reflect that state (e.g. a query parameter). It’s a pain to push the state upwards when its not relevant to any other child except that one child. Both ways are offered.

The problem with the above code is that it is tied to cats-effect. Not a bad thing overall but we can abstract that out as well. Let’s make a few small changes to make the component independent of cats/cats-effect.

Updated Solution

Let’s add a run parameter to apply that runs the F. We’ll assume like cats that running an F produces either a Throwable or a result. Of course, we make this move knowing that the concept of returning some type of co-product like Either is solidly built into some of the “run” signatures in cats-effect already :-).

class Fetcher[F[_], T](Name: String) {

  case class State[T](loadState: FetchState[T] = NotRequested)

  sealed trait Action
  case class Fetched(items: T) extends Action
  case class FetchError(message: String) extends Action
  case class Request(fetch: F[T]) extends Action

  val c = reducerComponent[State[T], Action](Name)
  import c.ops._

  type FetchCallback = F[T] => Unit
  type Runner = F[T] => (Either[Throwable, T] => Unit) => Unit

  /** Provide data loading status to the child. */
  def apply(
    /** Callback when fetch state changes. Convenience thunk to initiate fetch. */
    cb: (FetchState[T], FetchCallback) => ReactNode,
    /** Run a F[T] to obtain an error or a result. */
    run: Runner,
    /** Initial fetch. */
    initialValue: Option[F[T]]
  ) =
    c.copy(new methods {
      val initialState = _ => State()
      didMount = js.defined(self => initialValue.foreach(f => self.send(Request(f))))
      val reducer = (action, state, gen) => {
        action match {
          case Request(f) =>
            gen.updateAndEffect(state.copy(loadState=Fetching)) { self =>
              val process: Either[Throwable, T] => Unit =  {
                case Right(item) => self.send(Fetched(item))
                case Left(t) => self.send(FetchError(t.getMessage()))
              }
              run(f)(process)
            }
          case FetchError(message) =>
            gen.update(state.copy(loadState = Error(Some(message))))
          case Fetched(item) =>
            gen.update(state.copy(loadState = Success(item)))
        }
      }
      val render = self =>
      cb(
        self.state.loadState,
        f => self.send(Request(f))
      )
    })
}

We did the following:

  • Added a run parameter to the component that will run the F and produce an error or a result.
  • Removed the Effect context bounds from F since we do not need to assume cats-effect.
  • Changed the original “run” approach using cats to run in our reducer.

Now to run this under cats we just need to use what we pulled out:

case class CatsFetcher[F[_]: Effect, T](Name: String) extends Fetcher[F, T](Name) {
  val F = Effect[F]
  def apply(
    cb: (FetchState[T], FetchCallback) => ReactNode,
    initialValue: Option[F[T]] = None
  ) = super.apply(
    cb,
    f => eicb => F.runAsync(f)(asyncei =>
      F.toIO(F.pure[Unit](eicb(asyncei)))
    ).unsafeRunSync(),
    initialValue)
}

We generalized this component quite a bit while still allowing us to easily use cats-effect if we want to. You may noticed that we used subclassing here for convenience. We could have defined the run parameter as part of an object and just used function composition. Below, we combined the two:


case class CatsFetcher[F[_]: Effect, T](Name: String) extends Fetcher[F, T](Name) {
  def apply(
    cb: (FetchState[T], FetchCallback) => ReactNode,
    initialValue: Option[F[T]] = None
  ) = super.apply(cb, CatsFetcher.run, initialValue)
}

object CatsFetcher {
  def run[F[_]:Effect, T]: F[T] => (Either[Throwable, T] => Unit) => Unit = {
    val F = Effect[F]
    f => eicb => F.runAsync(f)(asyncei => F.toIO(F.pure[Unit](eicb(asyncei)))).unsafeRunSync()
  }
}

We can use it like:

val fetcher = CatsFetcher[IO, Seq[MyDomainObject]]("MyDomainObjectFetcher")
// child creates the fetch F
fetcher((fstate, fetch) => ... )
// parent (topdown) creates the fetch F
fetcher(
	cb = (fstate, fetch => ..., 
	initialValue = Some(myclient.getList[MyDomainObject]("/someurl"))
)

Of course, if we are fetching data for a view, the actual F will probably be the result of multiple fetches that must complete before we can show the view. For more on that topic, see this blog.

Revisit

If we want to use the fetcher to fetch all the content for a “view” like we just mentioned above, it needs to fetch more than just a single object. Generally, there is a mix of the client fetching multiple pieces of data to power a view and the server serving up a perfect set of data for a view in one request. And this mix changes over time as your application changes. Let’s assume you do not have a single perfect server fetch to rely on. In the above formulation, as mentioned in last paragraph above, we need to compose F carefully to reflect our view’s data needs–a list of data, some security information, etc. In reality we don’t have F[Seq[Item]] we really have:

case class Model(items: Seq[Item], security: Permissions, otherData: ...)

We may be able to survive on partial view data as well so some of the parameters could be Option with None values and we still might call that a success. We also want to ensure that if the fetch fails, we know why it failed. We may want to know about multiple failures as well if multiple failures occur during the fetch. In the above formulation we assumed an error type of Throwable but in reality the error type should really be parameter. Perhaps its a list-of-exceptions or a list of messages from exceptions or a list error objects translated from exceptions to make error reporting easier. Let’s take a look at this.

First, let’s restructure Fetcher to take an P and an E type parameter and pull in the FetchState types inside the class. With FetchState external to the Fetcher class, you could mix and match fetch state with different fetchers, if they had the same type signature. Let’s tighten the screws so that the fetch state only works with a specific Fetcher class—make them path dependent types. E needs to come out of our Runner processing function that runs the F. Now we must also deal with an Either[E,T] instead of an Either[Throwable,T] . P now stands for the content inside F and is the output of the effect. P needs to be broken out into an error part and a “data” part. Obviously, if we use Either directly in our P then it is a bit trivial and feels redundant. Keeping E separate allows us some useful abstraction that what you request in the fetch is not what you are forced to deliver to the component and there can be some intermediate content that helps disentangle the data and its context. With these changes we get basically the same Fetcher class but with P and E added in a few places:

class Fetcher[F[_], P, E, T](Name: String) {
  /** Load state passed to a child. Kept outside of Fetcher so avoid "crossing
   * streams." (mixing and matching FetchState and child).
   */
  sealed trait FetchState

  /** Load was successful, hold item. */
  case class Success(item: T) extends FetchState

  /** Load resulted in an error. */
  case class Error(content: E) extends FetchState

  /** Loading still in progress. */
  case object Fetching extends FetchState

  /** Initial state until a fetch request is made. */
  case object NotRequested extends FetchState

  // internal component state and actions
  case class State(loadState: FetchState = NotRequested)  
  sealed trait Action
  case class Fetched(item: T) extends Action
  case class FetchError(content: E) extends Action
  case class Request(fetch: F[P]) extends Action

  val c = reducerComponent[State, Action](Name)
  import c.ops._

  type FetchCallback = F[P] => Unit
  type Runner = F[P] => (Either[E, T] => Unit) => Unit

  /** Provide data loading status to the child. */
  def apply(
    /** Callback when fetch state changes. Convenience thunk to initiate fetch. */
    cb: (FetchState, FetchCallback) => ReactNode,
    /** Run a F to obtain an error or a result. */
    run: Runner,
    /** Initial fetch. */
    initialValue: Option[F[P]]
  ) =
    c.copy(new methods {
      val initialState = _ => State()
      didMount = js.defined(self => initialValue.foreach(f => self.send(Request(f))))
      val reducer = (action, state, gen) => {
        action match {
          case Request(f) =>
            gen.updateAndEffect(state.copy(loadState = Fetching)) { self =>
              val process: Either[E, T] => Unit =  {
                case Right(item) => self.send(Fetched(item))
                case Left(e) => self.send(FetchError(e))
              }
              run(f)(process)
            }
          case FetchError(content) =>
            gen.update(state.copy(loadState = Error(content)))
          case Fetched(item) =>
            gen.update(state.copy(loadState = Success(item)))
        }
      }
      val render = self =>
      cb(
        self.state.loadState,
        f => self.send(Request(f))
      )
    })
}

and we need to trivially update CatsFetcher although we just assume the error is Throwable in this case:

case class CatsFetcher[F[_]: Effect, T](Name: String)
    extends Fetcher[F, T, Throwable, T](Name) {
  def apply(
    cb: (FetchState, FetchCallback) => ReactNode,
    initialValue: Option[F[T]] = None
  ) = super.apply(cb, CatsFetcher.run, initialValue)
}

object CatsFetcher {
  def run[F[_]:Effect, T]: F[T] => (Either[Throwable, T] => Unit) => Unit = {
    val F = Effect[F]
    f => eicb => F.runAsync(f)(asyncei => F.toIO(F.pure[Unit](eicb(asyncei)))).unsafeRunSync()
  }
}

We were totally lazy in that we assumed the error would continue to be carried in F (a poorly typed Throwable) and hence P = T which is fine for simple asynchronous data cases.

Inside a view, we would define how we want to receive view data and data availability errors. Let’s assume that:

  • A view will have several data dependencies.
  • We need all the dependencies collected into a data structure (case class).
  • Exceptions from each dependency should be “added” together across all the dependency fetches. Our errors are not just a simple Throwable and cats-effect’s CompositeException is probably not the right answer (although it could be used to simplify things below, we 'll keep our error structures a bit more exposed).
  • Data dependencies should run in parallel.
  • We want to keep everything in scala.js vs javascript.
  • At the end of the world for our view, we will assume IO as our F (for our example here, could be generalized).

Normally we would create a centralized fetch manager with some specialized data structures to manage all of this and take care of cross-cutting concerns but with functional programming you can lighten up on this design and compose the parts you need at the “site” you need them–in this case the view. However, each view may have some unique requirements that we have to adopt to so lets keep it simple using cats/cats-effect and some glue for our example view.

// We can't represent the error as a simple Throwable in this case...so coproduct comes in
// inside the `F` and `F`'s implied Throwable becomes relevant for exceptional errors.

/** You can formulate your own application's approach to fetching, here's one example. */
package object Views {
	/** One model out of many for a view: data or a list of errors. */
	type ViewModel[A] = ValidatedNec[Throwable, A]

	/** Needed for parMapN, etc. */
	implicit val cs = IO.contextShift(scala.concurrent.ExecutionContext.Implicits.global)
	
	/** Run for Fetcher when using a ViewModel */
    def run[F[_]: Effect, T]:
      F[ViewModel[T]] => (Either[NonEmptyChain[Throwable],T] => Unit) => Unit = {
      val F = Effect[F]
      f => eicb => F.runAsync(f){asyncei =>
        F.toIO(F.pure[Unit](eicb(
          asyncei.leftMap(NonEmptyChain.one(_)).flatMap(_.toEither)
        )))
      }.unsafeRunSync()
    }
	...
}

Generally, we would create an intermediate layer that processes the load status and shows something relevant but we will make a component that deals with data availability (load state) directly. There are many ways to structure this aspect of the design. The reality is that LoadState should really be called DataAvailability or DataReadiness that captures the concept of “asynchronous data access”:

/** My react component view that receives the result of a fetch. Initiates its own fetch. */
package Views
object MyView {
	// the model for the view
    case class Model(items: ..., permissions: ..., other: ...)

	// the fetcher inside the view object for convenience
	val fetcher = new Fetcher[IO, ViewModel[Model], NonEmptyChain[Throwable], Model]("MyViewFetcher")

	// create the IO that fetches the data and creates a Model
	def requestData(arg: Option[Int]): IO[ViewModel[Model]] = {
		
		// client.getList() returns IO[Seq[Stuff]]]
		val itemIO = client.getList("ListofStuff").attempt.map(_.toValidatedNec)
		
		// client.getPermissions() returns IO[Permissions]
		val permissionsIO = client.getPermissions("stuffs").attempt.map(_.toValidatedNec)\
		
		// combine into a single IO, running dependencies in parallel then creating model
		(itemIO, permissionsIO).parMapN(Apply[ViewModel].map2(_,_)(Model.apply))
	}
	// all of the scalajs-react component content
	// ...
	def apply(
		loadStatus: fetcher.LoadStatus,
		query: fetcher.FetchCallback,
		...
	) = c.copy(new methods { ... })
}

The fetch result for a view becomes a bit more complicated. We need to pattern match out of it to render:

val render = self => {
	val (items, loading, errmsgs) = loadStatus match { 
	   case fetcher.Success(viewDataModel) => (viewDataModel.items, false, ...)
	   case fetcher.Error(nonEmptyListOfThrowables) => (Nil, false,
		    getListOfMessagesFrom(nonEmptyListOfThrowables)) 
	   case ... => ...
	  }
	div(...use items or loading....)
}

To use it, we can do:

MyView.fetcher(
  cb = (state, fetch) => MyView(
	  loadStatus = state,
	  query = fetch,
	  ...
  ),
  run = Views.run,
  initialValue = None
)

Obviously, we could centralize the fetcher code into another, complementary object related to MyView and assumes the effect, payload, and error model:

// Assumes a specific payload type and execution model using cats/cats-effect constructs.
// Assumes a specific F though except for convenience in makeFetcher.
object Views {
  // We can't represent the error as a simple Throwable in this case...so coproduct comes in.
  type ViewModel[A] = ValidatedNec[Throwable, A]

  /** Needed for parMapN. Import separately as `import Views.implicits._` */
  object implicits {
	  implicit val cs = IO.contextShift(scala.concurrent.ExecutionContext.Implicits.global)
  }
  // F[P]'s Throwable is added to ViewModel if present. */
  def run[F[_]: Effect, T]:
      F[ViewModel[T]] => (Either[NonEmptyChain[Throwable],T] => Unit) => Unit = {
    val F = Effect[F]
    f => eicb => F.runAsync(f){asyncei =>
      F.toIO(F.pure[Unit](eicb(
        asyncei.leftMap(NonEmptyChain.one(_)).flatMap(_.toEither)
      )))
    }.unsafeRunSync()
  }

  /** Create a new fetcher using ViewModel as the effect payload 
    * and NonEmptyChain[Throwable] for errors. Assumes IO for the effect.
    */
  def makeFetcher[T](name:String) =
    new Fetcher[
      IO,
      ViewModel[T],
      NonEmptyChain[Throwable],
      T
    ](name)
}

Composability

From the above, we can compose using:

  • Components: Fetcher is a react component.
  • The view’s model can be made extensible.
  • Fetching can be composed via F. For example, if we have a view take a parameter such as inform: IO[Unit] then when we create the data request we could use F.productR(inform)(originalDataRequest(...)) for our data request and the “inform” callback will run at the same time as the data fetch. F is very composable using any of the tricks in the cats and cats-effect libraries.
  • You can plug in different run algorithms for the fetcher. This allows the run algorithm to be tied into any other centralized instrumentation capabilities.
  • You can change the payload type.

Those are alot of options.

Recap

It’s important to point out that using some simple ingredients from cats and cats-effect we were able to formulate a fairly decently useful fetcher component that can be reshaped based on different ways to represent the effect, errors and the data content. Very few assumptions were made along the way. Our Fetcher class remained independent of cats and cats-effect and only focused on state management. We could do better on the language around FetchState and some of the vocabulary used was a bit “fetchy” when in reality the vocabulary should be around asynchronous data e.g. delayed availability with the potential for failure.

There are other ways to do the above and we may want to incrementally grab data for a view so more data management capabilities are needed for a view. You need flexibility to handle different data management approaches without trying to force a single approach for every view–something that leads to friction.

That’s it!

Comments

Post a Comment

Popular posts from this blog

quick note on scala.js, react hooks, monix, auth

zio environment and modules pattern: zio, scala.js, react, query management

user experience, scala.js, cats-effect, IO