Handling states : signed in or not, loading, error, got-the-data

This isn’t technically a Rescript question though maybe Functors or some other Rescript language features could help. I have a general requirement that some pages are only accessible to people who are signed in. Rather than making every such page handle the two cases - anonymous vs. signed-in - I am trying to make a general component called a Protected. Below is my best attempt. It seems to work but is a little ugly because I’ve got to put the “signed in” case inside brackets. I could use the children property of Protected for the signed-in variant so it is all easy-to-read JSX but then I can’t easily pass the captured user identity to that component. I’m thinking of using this same approach to make a generic component to handle components with “loading”, “error”, and “got the data” states. Many pages have all these states…

awaiting sign-in result
signed in guest
signed in registered user
   loading
   error getting the data
   got the data
not signed in

… and I don’t want to manually handle all these cases in every component. I’m sure this is super common issue and am curious how other people are solving it.

module User = {
  type t = {id: string, displayName: string}
  let getDisplayName = i => i.displayName
  let getUserId = i => i.id
  let bob = {id: "a54ghrkcd", displayName: "Bob Smith"}
}

module AccountDetails = {
  @react.component
  let make = (~user) => <div> {`Hello ${user->User.getDisplayName}!`->React.string} </div>
}

module Protected = {
  @react.component
  let make = (~ifSignedIn: User.t => React.element, ~useUser) => {
    let user = useUser()
    switch user {
    | None =>
      <div>
        {"You've got to be signed in first"->React.string}
        <button> {"Sign in"->React.string} </button>
      </div>
    | Some(user) => ifSignedIn(user)
    }
  }
}

module ProtectedStorybook = {
  @react.component
  let make = () => {
    <div>
      <h3> {"While signed in"->React.string} </h3>
      <Protected useUser={() => Some(User.bob)} ifSignedIn={u => <AccountDetails user=u />} />
      <h3> {"While anonymous"->React.string} </h3>
      <Protected useUser={() => None} ifSignedIn={u => <AccountDetails user=u />} />
    </div>
  }
}
1 Like

Couple of ways you could approach this, but one (fairly) simple way is to make a new type AuthorizedUser and then have any page that requires a user to be signed in can take AuthorizedUser instead of User.

The key is constructing that type in a way that doesn’t allow you to construct AuthorizedUser except after they pass whatever authentication system you have. You could do something like this:

module AuthorizedUser: {
  type t = private User.t

  // This is the ONLY way to create an AuthorizedUser.t.
  let authenticate: string => option<t>
} = {
  type t = User.t

  // Silly stand in for your real auth service.  Returns None if auth "fails".
  let runAuthService = name =>
    if name == "Ryan" {
      Some({User.id: "abc123", displayName: "Ryan"})
    } else {
      None
    }

  // Returns an Some(AuthenticatedUser.t) if auth succeeds, None otherwise.
  let authenticate = name => {
    Belt.Option.map(runAuthService(name), user => user)
  }
}

Now if you ever manage to get a AuthorizedUser.t you know it will be a good, logged-in user.

So then you can change your Storybook to something like this:

module Storybook = {
  // Storybook can only be created by passing in an AuthorizedUser.
  @react.component
  let make = (~user: AuthorizedUser.t) => {
    // This is "type coercion" so we can treat the user as a User.t.
    let user = (user :> User.t)
    <div> <h2> {React.string("Storybook")} </h2> <AccountDetails user /> </div>
  }
}

Storybook doesn’t need to deal with whether a user is logged in or not, because you can only create it with an authenticated user.


Here is a silly app to show how you might put it all together.

module User = {
  type t = {id: string, displayName: string}

  let getDisplayName = i => i.displayName
  let getUserId = i => i.id
}

module AuthorizedUser: {
  type t = private User.t

  // This is the ONLY way to create an AuthorizedUser.t.
  let authenticate: string => option<t>
} = {
  type t = User.t

  // Silly stand in for your real auth service.  Returns None if auth "fails".
  let runAuthService = name =>
    if name == "Ryan" {
      Some({User.id: "abc123", displayName: "Ryan"})
    } else {
      None
    }

  // Returns an Some(AuthenticatedUser.t) if auth succeeds, None otherwise.
  let authenticate = name => {
    Belt.Option.map(runAuthService(name), user => user)
  }
}

module AccountDetails = {
  @react.component
  let make = (~user) => <div> {`Hello ${user->User.getDisplayName}!`->React.string} </div>
}

module Storybook = {
  // Storybook can only be created by passing in an AuthorizedUser.
  @react.component
  let make = (~user: AuthorizedUser.t) => {
    // This is "type coercion" so we can treat the user as a User.t.
    let user = (user :> User.t)
    <div> <h2> {React.string("Storybook")} </h2> <AccountDetails user /> </div>
  }
}

module State = {
  // Your app would probably have a record here with more stuff to track.
  // For this though, we just have either an authorized user, or a guest.
  type t = SignedIn(AuthorizedUser.t) | Guest
}

// A little text input where you type your name and try to log in.
module SignIn = {
  @react.component
  let make = (~setUser) => {
    let onBlur = event => {
      let name = ReactEvent.Focus.target(event)["value"]

      switch AuthorizedUser.authenticate(name) {
      | Some(user) => setUser(_ => State.SignedIn(user))
      | None => {
          let _ = %raw(`alert("Login failed!")`)
          setUser(_ => State.Guest)
        }
      }
    }

    <div>
      <h2> {React.string("Sign in!")} </h2>
      <label> {React.string("Name")} <input type_="text" onBlur /> </label>
    </div>
  }
}

// Sign out by changing the app state back to Guest.
module SignOut = {
  @react.component
  let make = (~setUser) => {
    let onClick = _event => {
      setUser(_ => State.Guest)
    }

    <div> <button onClick> {React.string("Sign out!")} </button> </div>
  }
}

module App = {
  // Helper to make a storybook from an AuthorizedUser.t
  let storybook = (user, setUser) => {
    <div> <Storybook user /> <hr /> <SignOut setUser /> </div>
  }

  @react.component
  let make = () => {
    let defaultState = State.Guest
    let (user, setUser) = React.useState(() => defaultState)

    let element = switch user {
    | SignedIn(user) => storybook(user, setUser)
    | Guest => <SignIn setUser />
    }

    <div> element </div>
  }
}

// Render the app.
switch ReactDOM.querySelector("#app") {
| Some(root) => ReactDOM.render(<React.StrictMode> <App /> </React.StrictMode>, root)
| None => Js.log("No app element!")
}

Of course, your real app would be a lot fancier than this (and actually do something real)…but it’s just an example of one way to get the type system to enforce some stuff for you. And if you need some more complicated stuff for users like basic user, admin, guest, whatever, you can always switch to a GADT (generalized algebraic data type) if you need it.

1 Like

Thanks for your suggestions. I probably didn’t make myself clear. I’m not worried about accidentally “forging” a user identity. I understand how you can ensure a type is only created using a valid constructor. I’m looking for a higher-order component to handle the common states that many different pages share. I did some more googling for “react component loading error success states” and found a lot of suggestions. Probably the most relevant post was (5) Good strategy for handling loading/success/fail state on every page? : reactjs (reddit.com). Most of the suggestions involve enumerating all the valid states and then rendering different UI for each one. React error boundaries can be used to share error handling for a big component tree. For now, I’m probably just going to duplicate code in several components and will make a higher-order component similar to what I posted at the top of this message when the duplication becomes a hassle and when it is clearer to me how to refactor it.

I’ll probably track the authentication state using something like this…

type authenticationState =
  | Authenticating
  | Guest
  | SignedIn(User.t)

In any component that requires an authenticated user, I’ll make User.t a required property for that component. Outside that component (one level up) I’ll do a switch on the authentication state to decide whether to show the sign-in options or the actual component.

Inside those components, if I’m loading data, I’ll probably track the state using something like this and then do a switch to decide when to show a loading indicator, error message, or nothing if data has been downloaded and no operation is in progress.

type asyncProgress<'error> =
  | Idle
  | Loading
  | Error('error)

type dataState<'data, 'error> = {
  current: option<'data>,
  progress: asyncProgress<'error>,
}

Ah sorry for the confusion…I definitely misinterpreted your question!