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

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

Reactjs hooks finally came out of preview in a recent version of React. scalajs-reaction has had support for hooks since they were in preview. React hooks seem like the hot new thing but for me the question is, do hooks help improve your code by improving maintainability, time to market, TCO, etc.?

Let’s take a look using authentication as an example and employ monix, a reactive observable framework that also builds on cats. We will assume we are building a SPA.

Does it improve your code and make your application & development experience better?

Auth

Auth processing can be complex. Many react auth examples you find in blogs like to show how to use “react-routing” and “auth” as a switch that determines which routes are protected or unprotected. Protected routes show application specific data. Unprotected routes show login content . Based on storing a “User” object in the browser session/local storage, the protected or unprotected routes are conditionally rendered into the DOM. Both “protected” and “unprotected” components are in the tree and match against the URL but may render to “null” depending on the “getUser()” return value and match result.

In reality, auth and react component management is much more complex as auth requires re-authentication, the use of third-party libraries such has auth0, and user-experience designs that make the timing of auth state changes more challenging to model–think finite state machine (FSM). When a SPA first starts, you also typically check state (browser storage or remote) to determine if the user is already logged in. In some cases, you are only considered “logged in” if you have an id token, access token and an user profile object. If not, your app may show a spinner screen until an asynchronous process to fetch this information completes. For the best user experience, you may show the spinner for no less than 1 second because otherwise it flickers on the screen and does not appear to be a smooth experience. And if you open another tab using right click on a link in another app tab, ensure that auth an routing is correct! Getting the logic right to maximize the user experience can be tricky.

State Management

It is easy to imagine baking auth management into a global state design like redux or some other equivalent global state management library. redux is a popular javascript library for managing global state. There’s nothing wrong with redux. However, placing all application state into a single global state object can cause a mess. There are a variety of complexities introduced once you take this approach including the need to use “selectors” and “state slicing” to help you deal with performance and complex data structures. mobx tries to solve the problems encountered in redux-like solutions. Spoiler alert, monix can be used like mobx for some designs.

In addition, some believe that the URL is a reflection of application state at least in the HTTP protocol way of thinking. There is probably always going to be “state” living in some “global variable”. While you might hook up “history API” events to push URL changes into redux, the reality is that libraries like react-router directly modify the URL via the history URL API. Designs then rely on an “eventing to redux” mechanism to propagate changes from the browser to the redux state. In other words, there is almost always something pushing or pulling data into your global state that can also considered “global state.” Global is relative!

A different more nuanced model for application state is to move state that affects the UI to the highest, but not the highest, possible level in the component tree based on the state data by components to render. Render props or other mechanisms (you don’t even need React context) can decouple major levels/parts of your UI component tree. All other non-UI related state does not really belong in the UI-related state unless you intentionally want it to be that way. What usually happens though is that all the application state winds up in the a single global redux store–not always the best answer.

If there is no single source of truth in application state, you still need to keep state management as simple as possible, perhaps 3-4 major state management mechanisms are sufficient as long as each part has well drawn boundaries between i.e. minimize coupling, maximize cohesion. You will also need a way to communicate state changes and update the UI.

In the auth state management solution below, we use what looks like a mini redux store that is only focused on one concern, auth. It’s not that the redux approach is wrong, it is just that it often used to hold all application state. Using a more nuanced slice and approach may be helpful and easier in some situations.

Auth, Monix & React Hooks

Auth is a good candidate for being a major global state object. While you could create a react component that holds the state management, alternatively, we can author state management/publishing functionality without using a react component. This approach can make testability and resuability better. To move state management outside of a react component, lets just break down the major parts of auth and use the library of choice to handle the “stream” of auth events that arise.

An event bus is a common choice but let’s use a monix stream for pub/sub and auth event streaming. Then, let’s use react hooks to offer a react component API for convenience.

Overall, we want to create a scala “auth” component:

object Auth {
...
}

and then add in a reducer. Think of this as a slice of a global redux-based state and pub/sub mechanism. We’ll need to define State and Actions for the reducer. To keep it simple, we will assume that the logged in state does not have any data associated with it or that’s it retrievable another way. For example, you could add an User object and picture to the LoggedIn state below.

object Auth {

  import monix.reactive._
  import monix.eval._
  import monix.execution._
  import monix.reactive.subjects.ConcurrentSubject
  import monix.execution.Scheduler.Implicits.global

  private val publisher: ConcurrentSubject[State,State] =
    ConcurrentSubject[State](MulticastStrategy.behavior(LoggedOut))

  /** Send an action. You can run task with `.runAsyncAndForget` or use
   * combinators for other effects to detect when the send Task fails. Updates
   * state then broadcasts the change.
   */
  def dispatch(action: Action, effect: Task[Unit] = Task.unit): Task[Ack] = 
    setState(reducer(_state, action))
      .flatMap(state => Task.deferFuture(publisher.onNext(state)))
      .guarantee(effect)
      
  /** Subscribe to auth state changes. Run the task to enact subscription. */
  def subscribe(consumer: Consumer[State, Unit]): Task[Unit] =
    publisher.consumeWith(consumer)

  /** auth state */
  sealed trait State
  case object LoggedOut extends State
  case object LoggedIn extends State // normally have data here e.g. User object.
  case object LoggingIn extends State // perhaps, use this to show a spinner

  /** current state, on js platform, simple var is enough. */
  private var _state: State = LoggedOut

  /** Side effect to set state and return the new state. On js platform, simple
   * assignment is enough, otherwise need async ref.
   */
  private def setState(next: State) = Task{_state = next; next}

  sealed trait Action
  case class Changed(next: State) extends Action

  // General purpose reducer, in this case it does not need to do much :-)
  lazy val reducer: (State, Action) => State = { (current, action) =>
    action match {
      case Changed(next) =>
        //println(s"$Name.reducer: current=$current, next=$next")
        // could do other processing here
        next
    }
  }
  // ...rest of Auth
}

Using monix, we only need a few lines to create the pub/sub and subscribe methods. We have a “dispatch/send” method to send a new Auth state to listeners as well as set internal state. monix has a “Task” abstraction that delays computations and must be explicitly “run” to execute the computation described inside the task.

Because we are using scala.js on javascript and javscript is single threaded from the programmer’s point of view, we can use a “set” statements to modify _state versus using a more protected version like a concurrent friendly “ref.” If we wanted to allow service workers to modify _state we might handle this differently but it works good enough for our needs here.

The break out of these parts looks like a react component with the state management separated out. Any non-react component can use Auth’s state management mechanism, if that’s what we want. So now, auth state can be changed using a clean API from any callback, say a js.Promise, imperatively, in a 3rd-party auth library callback or via some other server side push mechanism that supports SSO. We can also setup a timer based on token expiration to improve the user experience e.g. save a component’s form data so we can return to the form once a token renewal process has run.

It’s important to note that hooks are not pure functions–not even close. The rely on some simple javascript tricks in the background to update values that affect how the function performs. While we should care about pure functions and strive for that, we can test many of the underlying functions that go into a react hook as the underlying functions can be pure functions. Maybe that is good enough. React components are not pure functions so everything else is lipstick on the pig.

To make this easy to consume, let’s write a react hook. A react hook is a javascript function that calls the React hook API and follows a few rules. It does not return a react component. This “hook” is meant to be called inside a react stateless functional component (SFC) that returns a react component, which actually makes it statefull where the “state” is then kept inside of react. In a way, the hook “indexes” into the react layer’s state management functionality and calls the SFC when that state changes.

object Auth {
  // ...
  /** React hook to subscribe to Auth state changes. Using js.Function2 as the
   * type forces the function to be a pure javascript function.
   * @param cb Callback when state changes.
   * @param error Callback when error happens during registration.
   */
  val authHook: js.Function2[
    Auth.State => Unit,
    Throwable => Unit,
    Unit
  ] = { (cb, error) =>
    // React.useEffect: specialaized va "empty array" dependencies param
    ttg.react.React.useEffectMounting(() => {
      val cancellable =
        Auth
          .subscribe(Consumer.foreach[Auth.State](cb(_)))
          .runAsync {
            case Right(_) => ()
            case Left(t) => error(t)
          }
          () => cancellable.cancel()
    })
  }
  // ...
  def init(): Unit = {
    // hookup to history API
    // ...
    // determine if logged in status is still valid e.g.
    // check browser storage, 3rd-party API
    if(stillLoggedIn) dispatch(Changed(LoggedIn))
  }
  init() // hookup to the history API when the Auth object is creeated
  // ...

The hook takes takes two callback parameters for when the state changes and if an error occurs. We could also bake in the callbacks as other types of effects but that’s overkill for what’s needed.

Using the Auth hook

We can use the hook in a react component found in the scalajs-reaction library. SFC1 creates a stateless functional component. A SFC is a javascript function that takes a single js object parameter.

// Toplevel component that itself uses hooks.
object Application {
  val Name = "Application"
  // ...
  case class State(auth: Auth.State, ... )
  sealed trait Action
  case class AuthChanged(next: Auth.State) extends Action

  val reducer: (State, Action) => State = { (state, action) =>
    action match {
      case AuthChanged(next) => state.copy(next = next) 
  }}
  
  case class ChildProps(/*...*/)
  
  trait Props extends js.Object {
    // equivalent to a render prop
    val child: ChildProps => ReactNode
  }
  
  def apply(props: Props) = sfc(props)
  
  val sfc = SFC1[Props]{ props =>
    React.useDebugValue(Name)
    val (state, dispatch) = React.useReducer[State,Action](reducer, State())
    Auth.authHook(
      authState => dispatch(AuthChanged(authState)),
      t => logger.error(s"$Name: Unable to register for auth state changes. $t")
    )

    // css-in-js using Microsoft fabric, memoized
    val cn = getClassNames(resolve[StyleProps, Styles](new StyleProps {
      className = props.rootClassName
    }, getStyles, props.styles))

    def cprops =
      // render prop value for a child
      ChildProps(
        // cb for which panel is showing
        s => state.shows.get(s).getOrElse(false),
        // dispatch a "show panel" change
        (s,f) => dispatch(Show(s,f)),
        // className
        cn.appBar,
        // className
        cn.body,
        // isLoggedIn
        state.auth == Auth.LoggedIn
      )
      
    divWithClassname(cn.root,
      state.auth match {
        case Auth.LoggingIn => Waiting(new Waiting.Props {
          message = "Preparing the application..."
        })
        case Auth.LoggedOut => Login(new Login.Props {
          message = "Please log in."
        }}
        case  => props.child(cprops)
      }
    )
  }
  // ...

You should notice that we did not use a router library but we could have.

Does it help?

Well…I think it is debatable. Using hooks does not make impure function pure. Using the rest of scalajs-reaction’s library can help here as it has a “react component” type that follows ReasonReact’s API which is already an improvement and has a bulti-in router. To change the pure/impure aspect, the API becomes much more complicated.

In the end, we really had about the same amount of code compared to scalajs-reaction (which is a big improvement over standard reactjs) but it was nice that using monix and scala.js we could pull that code out into another object alot easier than before. Using hooks is a bit more succinct than even scalajs-reaction’s component interface but not by much. By having much of the code outside a react component and using pure functions for those, testability did improve.

One thing I do not like about hooks is that if I want to run an effect after a state change, the reducer API does not allow this. In scalajs-reaction, that follows the ReasonReact model, the reducer can be explicit about generating a state change or not, as well as add an effect that runs after the state change–it’s in the API. With hooks, you have to add in a useEffect hook inside another hook to compose the two hooks together. Not hard to do, but more work than scalajs-reaction’s component model with a builtin reducer.

Our Auth object can be used in react-native without change. scalajs-reaction supports react-native. We could use the hook in a PWA, a react-native application or a PWA–three compelling environments for scala.js where bundle size is less critical.

If we wanted to design application state that can be broken out into major sub-components and have these components be relatively independent of react and each other, it did help. Using react hooks, monix and scala.js feels like a win.

That’s it!

Comments

Post a Comment

Popular posts from this blog

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

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