gemini-hlsw / crystal   0.33.9

GitHub

Functional, tagless and lens-based, global state management. With scalajs-react fs2.Stream integrations.

Scala versions: 2.12 2.13 3.x
Scala.js versions: 0.6 1.x

Crystal

Scala Steward badge Build Status Maven Central

crystal is a toolbelt to help build reactive UI apps in Scala by providing:

  • A structure for managing delayed values (Pot, PotOption).
  • Wrappers for values derived from state with a callback function to modify them (View, ViewOpt, ViewList).
  • A way to delegate reusability to another type (Reuse). Useful for types for which universal reusability cannot be defined (like functions or VdomNode).
  • Tight integration between scalajs-react and cats-effect + fs2.Streams via hooks.

crystal assumes you use a scalajs-react's core-bundle-cb_io, where the default sync effect is CallbackTo and the default async effect is IO. However, the library should compile with another bundle.

Core

Pot[A]

A Pot[A] represents a value of type A that has been requested somewhere and may or not be available yet, or the request may have failed.

It is a sum type consisting of:

  • Pending.
  • Ready(<A>).
  • Error(<Throwable>).

The crystal.implicits.* import will provide:

  • Instances for cats MonadError, Traverse, Align and Eq (as long as there's an Eq[A] in scope).
  • Convenience extension methods: <Any>.ready, <Option[A]>.toPot, <Try[A]>.toPot and <Option[Try[A]]>.toPot.

The crystal.react.implicits.* import will provide:

  • Reusability[Pot[A]] (as long as there's a Reusability[A] in scope).
  • Extesion methods:
    • renderPending(f: => VdomNode): VdomNode
    • renderError(f: Throwable => VdomNode): VdomNode
    • renderReady(f: A => VdomNode): VdomNode

PotOption[A]

Similar to Pot[A] but provides one additinal state. Its values can be:

  • Pending.
  • ReadyNone.
  • ReadySome(<A>).
  • Error(<Throwable>).

It is useful in some situations (see Hooks below).

scalajs-react

View[A]

A View[A] wraps a value of type A and a callback to modify it effectfully: (A => A) => Callback.

It is useful for passing state down the component hierarchy, allowing descendants to modify it.

It provides several zoom functions for drilling down its properties. It also allows effects to be chained whenever it's modified (withOnMod).

ViewOpt[A] and ViewList[A] are variants that hold a value known to be an Option[A] or List[A] respectively. They are returned when zooming using Optional, Prism or Traversal.

Reuse[A]

A Reuse[A] wraps a value of type A and a hidden value of another type B such that there is a implicit Reusability[B].

The instance of Reuse[A] will be reused as long as the associated value B can be reused.

This is useful to define Reusability for types where universal reusability can't be defined. For example, we could define reusability for a function (S, T) => U can be turned into a Reuse[T => U] by specifying a curried value of S (and assuming there's a Reusability[S] in scope).

Hooks

useSingleEffect

Provides a context in which to run a single effect at a time.

When a new effect is submitted, the previous one is canceled. Also cancels the effect on unmount.

A submitted effect can be explicitly canceled too.

If a debounce is passed, the hooks guarantees that effect invocations are spaced at least by the specified duration.

  useSingleEffect(): Reusable[UseSingleEffect]

  useSingleEffect(debounce: FiniteDuration): Reusable[UseSingleEffect]

  useSingleEffectBy(debounce: Ctx => FiniteDuration): Reusable[UseSingleEffect]

where

trait UseSingleEffect:
  def submit(effect: IO[Unit]): IO[Unit]
  val cancel: IO[Unit]

Example:

ScalaFnComponent
  .withHooks[Props]
  ...
  .useSingleEffect(1.second)
  .useEffectWithDepsBy( ... => deps)( (..., singleEffect) => deps => singleEffect.submit(longRunningEffect) )
  // Previous `longRunningEffect` is cancelled immediately and new one is ran after 1 second

useStateCallback

Class components allow us to specify a callback when we modify state. The callback is executed when state is modified and is passed the new state value.

This is not available in functional components. This hook seeks to emulate such functionality.

Given a state created with .useState, the hook allows us to register a callback that will be ran once, the next time the state changes. The callback will be passed the new state value.

  useStateCallbackBy[A](state: Ctx => Hooks.UseState[A]): (A => Callback) => Callback

Example:

ScalaFnComponent
  .withHooks[Props]
  ...
  .useState(SomeValue)
  .useStateCallbackBy( (..., state) => state)
  .useEffectBy( (..., state, stateCallback) => 
    state.modState(...) >> stateCallback(value => effect(value))
  ) // `effect` is run with new `value`, after `modState` completes.

useStateView

Provides state as a View.

Functionally equivalent to useState but the View is more practical to pass around to child components, zoom into members and define callbacks upon state change.

  useStateView[A](initialValue: => A): View[A]

  useStateViewBy[A](initialValue: Ctx => A): View[A]

useStateViewWithReuse

Similar to useStateView but returns a Reuse[View[A]]. The resulting View is reused by its value and thus requires an implicit Reusability[A], as well as a ClassTag[A].

  useStateViewWithReuse[A: ClassTag: Reusability](initialValue: => A): Reuse[View[A]]

  useStateViewWithReuseBy[A: ClassTag: Reusability](initialValue: Ctx => A): Reuse[View[A]]

useSerialState

Creates component state that is reused as long as it's not updated.

  useSerialState[A](initialValue: => A): UseSerialState[A]

  useSerialStateBy[A](initialValue: Ctx => A): UseSerialState[A]

where

trait UseSerialState[A]:
  val value: Reusable[A]
  val setState: Reusable[A => Callback]
  val modState: Reusable[(A => A) => Callback]

Reusability of UseSerialState[A] and its members depends on an internal counter which is updated every time the wrapped value changes. This is useful to provide stable reusability to types where we don't have or can't define Reusability instances.

Usage is the same as for .useState/.useStateBy.

useSerialStateView

Version of useSerialState that returns a Reuse[View[A]].

  useSerialStateView[A](initialValue: => A): Reuse[View[A]]

  useSerialStateViewBy[A](initialValue: Ctx => A): Reuse[View[A]]

useAsyncEffect

Version of useEffect that allows defining an async effect with an (also async) cleanup effect.

useEffect allows defining a cleanup effect only when used with the default sync effect (usually CallbackTo).

This hook should only be used when a cleanup effect is needed. To use a regular async effect, just use regular useEffect.

  useAsyncEffect(effect: IO[IO[Unit]])

  useAsyncEffectBy(effect: Ctx => IO[IO[Unit]])

  useAsyncEffectWithDeps[D: Reusability](deps: => D)(effect: D => IO[IO[Unit]])

  useAsyncEffectWithDepsBy[D: Reusability](deps: Ctx => D)(effect: Ctx => D => IO[IO[Unit]])

  useAsyncEffectOnMount(effect: IO[IO[Unit]])

  useAsyncEffectOnMountBy(effect: Ctx => IO[IO[Unit]])

useEffectResult

Stores the result A of an effect in state. The state is provided as Pot[A], with value Pending until the effect completes (and Error if it fails).

  useEffectResult[A](effect: IO[A]): Pot[A]

  useEffectResultBy[A](effect: Ctx => IO[A]): Pot[A]

  useEffectResultWithDeps[D: Reusability, A](deps: => D)(effect: D => IO[A]): Pot[A]

  useEffectResultWithDepsBy[D: Reusability, A](deps: Ctx => D)(effect: Ctx => D => IO[A]): Pot[A]

  useEffectResultOnMount[A](effect: IO[A]): Pot[A]

  useEffectResultOnMountBy[A](effect: Ctx => IO[A]): Pot[A]

Example:

ScalaFnComponent
  .withHooks[Props]
  ...
  .useEffectResultOnMount(UUIDGen.randomUUID)
  .render( (..., uuidPot) => 
    uuidPot.fold(
      "Pending...",
      t => s"Error! ${e.getMessage}",
      uuid => s"Your fresh UUID: $uuid"
    )
  )

useResource

Opens a Resource[IO, A] upon mount or dependency change, and provides its value as a Pot[A].

The resource is gracefully closed upon unmount or dependency change.

Note that there is no version without deps or onMount since it doesn't make sense to open a resource in each render, especially taking into account that once the resource is acquired it will force a rerender.

  useResource[D: Reusability, A](deps: => D)(resource: D => Resource[IO, A]): Pot[A]

  useResourceBy[D: Reusability, A](deps: Ctx => D)(resource: Ctx => D => Resource[IO, A]): Pot[A]

  useResourceOnMount[A](resource: Resource[IO, A]): Pot[A]

  useResourceOnMountBy[A](resource: Ctx => Resource[IO, A]): Pot[A]

useStream

Executes and drains a fs2.Stream[IO, A] upon mount or dependency change, and provides the latest value from the stream as a PotOption[A].

The fiber evaluating the stream is canceled upon unmount or dependency change.

Note that there is no version without deps or onMount since it doesn't make sense to open a resource in each render, especially taking into account that starting the draining fiber will force a rerender, as well as every new value produced.

  useStream[D: Reusability, A](deps: => D)(stream: D => fs2.Stream[IO, A]): PotOption[A]

  useStreamBy[D: Reusability, A](deps: Ctx => D)(stream: Ctx => D => fs2.Stream[IO, A]): PotOption[A]

  useStreamOnMount[A](stream: fs2.Stream[IO, A]): PotOption[A]

  useStreamOnMountBy[A](stream: Ctx => fs2.Stream[IO, A]): PotOption[A]

The resulting PotOption[A] takes one of these values:

  • Pending: Fiber hasn't started yet
  • ReadyNone: Fiber has started but no value has been produced by the stream yet.
  • ReadySome(a): a is the last value produced by the stream.
  • Error(t): Fiber raised an exception t.

useStreamView

Like useStream but returns a PotOption[View[A]], allowing local modifications to the state once it's Ready.

In other words, the state will be modified on every new value produced by the stream, and also on every invocation to set or mod on the View.

  useStreamView[D: Reusability, A](deps: => D)(stream: D => fs2.Stream[IO, A]): PotOption[View[A]]

  useStreamViewBy[D: Reusability, A](deps: Ctx => D)(stream: Ctx => D => fs2.Stream[IO, A]): PotOption[View[A]]

  useStreamViewOnMount[A](stream: fs2.Stream[IO, A]): PotOption[View[A]]

  useStreamViewOnMountBy[A](stream: Ctx => fs2.Stream[IO, A]): PotOption[View[A]]

useStreamResource

Given a Resource[IO, fs2.Stream[IO, A]], combines useResource and useStream on it.

In other words, when mounting or depdency change, the resource is allocated and the resulting stream starts being evaluated.

Upon unmount or dependency change, the evaluating fiber is cancelled and the resource closed.

  useStreamResource[D: Reusability, A](deps: => D)(streamResource: D => Resource[IO, fs2.Stream[IO, A]]): PotOption[A]

  useStreamResourceBy[D: Reusability, A](deps: Ctx => D)(streamResource: Ctx => D => Resource[IO, fs2.Stream[IO, A]]): PotOption[A]

  useStreamResourceOnMount[A](streamResource: Resource[IO, fs2.Stream[IO, A]]): PotOption[A]

  useStreamResourceOnMountBy[A](streamResource: Ctx => Resource[IO, fs2.Stream[IO, A]]): PotOption[A]

useStreamResourceView

Given a Resource[IO, fs2.Stream[IO, A]], combines useResource and useStreamView on it.

Like useStreamResource but returns a PotOption[View[A]], allowing local modifications to the state once it's Ready.

  useStreamResourceView[D: Reusability, A](deps: => D)(streamResource: D => Resource[IO, fs2.Stream[IO, A]]): PotOption[View[A]]

  useStreamResourceViewBy[D: Reusability, A](deps: Ctx => D)(streamResource: Ctx => D => Resource[IO, fs2.Stream[IO, A]]): PotOption[View[A]]

  useStreamResourceViewOnMount[A](streamResource: Resource[IO, fs2.Stream[IO, A]]): PotOption[View[A]]

  useStreamResourceViewOnMountBy[A](streamResource: Ctx => Resource[IO, fs2.Stream[IO, A]]): PotOption[View[A]]

scalajs-react <-> cats-effect interop

The crystal.react.implicits.* import will provide the following methods:

Effect conversion

  • <CallbackTo[A]>.to[F]: F[A] - converts a CallbackTo to the effect F. <Callback>.to[F] returns F[Unit]. (Requires implicit Sync[F]).
  • <F[A]>.runAsync(cb: Either[Throwable, A] => F[Unit]): Callback - When the resulting Callback is run, F[A] will be run asynchronously and its result will be handled by cb. (Requires implicit Dispatcher[F]).
  • <F[A]>.runAsyncAndThen(cb: Either[Throwable, A] => Callback): Callback - When the resulting Callback is run, F[A] will be run asynchronously and its result will be handled by cb. The difference with runAsyncCB is that the result handler returns a Callback instead of F[A]. (Requires implicit Dispatcher[F]).
  • <F[A]>.runAsyncAndForget: Callback - When the resulting Callback is run, F[A] will be run asynchronously and its result will be ignored, as well as any errors it may raise. (Requires implicit Dispatcher[F]).
  • <F[Unit]>.runAsyncAndThen(cb: Callback, errorMsg: String?): Callback - When the resulting Callback is run, F[Unit] will be run asynchronously. If it succeeds, then cb will be run. If it fails, errorMsg will be logged. (Requires implicit Dispatcher[F] and Logger[F]).
  • <F[Unit]>.runAsync(errorMsg: String?): Callback - When the resulting Callback is run, F[Unit] will be run asynchronously. If it fails, errorMsg will be logged. (Requires implicit Dispatcher[F] and Logger[F]).

Please note that in all cases the the Callback returned by .runAsync* will complete immediately.

Extensions to BackendScope

  • <BackendScope[P, S]>.propsIn[F]: F[P] - (Requires implicit Sync[F]).
  • <BackendScope[P, S]>.stateIn[F]: F[S] - (Requires implicit Sync[F]),
  • <BackendScope[P, S]>.setStateIn[F](s: S): F[Unit] - will complete once the state has been set. Therefore, use this instead of <BackendScope[P, S]>.setState.to[F], which would complete immediately. (Requires implicit Async[F]).
  • <BackendScope[P, S]>.modStateIn[F](f: S => S): F[Unit] - same as above. (Requires implicit Async[F]).
  • <BackendScope[P, S]>.modStateWithPropsIn[F](f: (S, P) => S): F[Unit] - (Requires implicit Async[F]).