Skip to content
Horacio Herrera

Build an Auth flow using React and XState

english, tutorial, React, XState, Auth4 min read

Photo by Matt Duncan on Unsplash

Photo by Matt Duncan on Unsplash

TLDR: checkout the final machine here

UPDATE: I rename the callback to send as a suggestion from Erik and I mention some resources created by Matt Pocock


One of the key libraries we use to develop the Mintter app is XState. In this short post, I want to show you how are we using it to check if a user is logged in or logged out, and change the app behaviour depending on the result.

What is XState?

XState is a library for creating, interpreting, and executing finite state machines and statecharts, as well as managing invocations of those machines as actors

I took this definition from the official website, and if you are not familiar with the concepts finite state machines or actors don't worry, it's not as complex as it sounds!

You can expand your knowledge about it in the official documentation, but in short, XState helps you define in a declarative way all the business logic of your application, making it easy to intercept, interact and respond to it with full confidence. Honestly, is one of the most important discoveries I've personally made on programming recently.

Why State Machines?

Not only helps you define your business logic in a more concise way, but also helps you communicate better with designers and other non-developer teammates, making your product more robust and future-proof. You should definitely give it a try!

The Auth flow

if you don't have much time to read all the explanation, you can checkout the machine here

In Mintter, everytime a user opens the desktop app, we need to check if an account is present on the machine or not. Let's start by defining all the requirements we need to implement:

  • when the user opens the app, we need to check if there's an account available
  • if the account is available, we can show the app view
  • if the account is NOT available, we need to show the onboarding view to create an account

In this post we are just covering this part of the flow, the other parts will be covered in future blog posts!

From the top list you can see that we defined 3 states in which the user can be at any point in time. let's see the list again:

  • when the user opens the app, we need to check if there's an account available
  • if the account is available, we can show the app view
  • if the account is NOT available, we need to show the onboarding view to create an account

we can then rename this states to something more meaningful like:

  • checkingAccount
  • loggedIn
  • loggedOut

So far so good!, let's also visualize this with a diagram:

Simple states diagram

and here's the code that creates this diagram:

1import { createModel } from "xstate/lib/model"
2
3const authModel = createModel({
4 account: undefined as string | undefined,
5})
6
7const authMachine = authModel.createMachine({
8 initial: "checkingAccount",
9 states: {
10 checkingAccount: {},
11 loggedIn: {},
12 loggedOut: {},
13 },
14})

By the way, what we are creating is a machine, more specifically: a Finite State Machine, because we are defining a finite number of states that this machine can be and transition to. (read more about it here)

In order to transition from one state to another, we need to define a couple of events. The machine can receive as many events as you like, but each state needs to define which events wants to respond to.

For this machine, we want to transition from checkingAccount to loggedIn if the account is present, we can call this event REPORT_ACCOUNT_PRESENT. if the account is not present, then we want to transition to loggedOut: we can call this event REPORT_ACCOUNT_MISSING. We also need LOG_IN and LOG_OUT events for the basic actions from the app. Let's add the events to our model:

1import { createModel } from "xstate/lib/model"
2
3const authModel = createModel(
4 {
5 account: undefined as string | undefined,
6 },
7 {
8 events: {
9 LOGGED_IN: (account: string) => ({ account }),
10 LOGGED_OUT: () => ({}),
11 REPORT_ACCOUNT_PRESENT: (account: string) => ({ account }),
12 REPORT_ACCOUNT_MISSING: () => ({}),
13 },
14 }
15)
16
17const authMachine = authModel.createMachine({
18 initial: "checkingAccount",
19 states: {
20 checkingAccount: {},
21 loggedIn: {},
22 loggedOut: {},
23 },
24})

I'm using the createModel utility because it gives better type safety when using the machine. you can use the common createMachine if you like too!

Invoking another Actor

For this Auth flow, we want to inmediately make a request to the backend and check if the user is available or not, and a common and recommended way to do so is by invoking a Service or Actor. Invoking a service is not different from calling an asynchronous function to get some data.

As you can see in the above snippet, we are starting our machine on the checkingAccount state, this means that we need to invoke our call to the API inside that state like so:

1// ...
2
3const authMachine = authModel.createMachine({
4 initial: "checkingAccount",
5 states: {
6 checkingAccount: {
7 invoke: {
8 id: "authMachine-fetch",
9 src: "fetchAccount",
10 },
11 },
12 loggedIn: {},
13 loggedOut: {},
14 },
15})

The fetchAccount service or Actor will be executed right after the machine enters the checkingAccount state. It is recommended to define your services and actions inside the machine as strings, and implement them in the second parameter of the createMachine function or by extending the machine with a withConfig call (see more here). Let's implement our actor now:

1// ...
2
3const authMachine = authModel.createMachine(
4 {
5 initial: "checkingAccount",
6 states: {
7 checkingAccount: {
8 invoke: {
9 id: "authMachine-fetch",
10 src: "fetchAccount",
11 },
12 },
13 loggedIn: {},
14 loggedOut: {},
15 },
16 },
17 {
18 services: {
19 fetchAccount: () => (send) => {
20 return getAccount()
21 .then(function (info) {
22 send({ type: "REPORT_ACCOUNT_PRESENT", account })
23 })
24 .catch(function (err) {
25 send("REPORT_ACCOUNT_MISSING")
26 })
27 },
28 },
29 }
30)
31
32function getAccount() {
33 return Promise.resolve("THE ACCOUNT")
34}

Let's talk about the actual implementation of fetchAccount. As you can see is a curried function, the first function gets as parameters the machine's context and the event, but for this actor we don't need those so we avoid them. the second function takes to arguments too, the first one is a send and the second one is an event listener called onReceive

  • send let you send events to the parent machine
  • onReceive let you listen to events sent to the parent

In our machine, the parent is the actual auth machine we are defining, since this is all defined from the actor perspective (the fetchAccount function). having access to this send is what we need to transition to the appropiate states depending on the request result! As you can see in the implementation, we are calling the send passing the appropiate event based on the fetchAccount result.

If you want to learn more about the Invoked Callback pattern, you can checkout this post from Matt Pocock

and that's it!, with this machine in place, we can then render the appropiate components based on the current state in which the machine is in, feeling very confident we are not going to get any weird errors or wrong renderings.

Or course this is a very small subset of all the machines and events we implement in our app, I will continue sharing more and more about how XState is helping us building our app with confidence and ease.

Here's the final Diagram and code. You can also play and fork this machine in the Stately registry (feel free to like it too!). Here's also a very simple implementation of the machine in a React Application.

Final Machine Diagram

1import { createModel } from "xstate/lib/model"
2
3export const authModel = createModel(
4 {
5 account: undefined as string | undefined,
6 },
7 {
8 events: {
9 LOGGED_IN: (account: string) => ({ account }),
10 LOGGED_OUT: () => ({}),
11 REPORT_ACCOUNT_PRESENT: (account: string) => ({ account }),
12 REPORT_ACCOUNT_MISSING: () => ({}),
13 },
14 }
15)
16
17export const authStateMachine = authModel.createMachine(
18 {
19 id: "authStateMachine",
20 context: authModel.initialContext,
21 initial: "checkingAccount",
22 states: {
23 checkingAccount: {
24 invoke: {
25 id: "authMachine-fetch",
26 src: "fetchAccount",
27 },
28 on: {
29 REPORT_ACCOUNT_PRESENT: {
30 target: "loggedIn",
31 actions: [
32 authModel.assign({
33 account: (_, ev) => ev.account,
34 }),
35 ],
36 },
37 REPORT_ACCOUNT_MISSING: {
38 target: "loggedOut",
39 actions: [
40 authModel.assign({
41 account: undefined,
42 }),
43 ],
44 },
45 },
46 },
47 loggedIn: {},
48 loggedOut: {},
49 },
50 },
51 {
52 services: {
53 fetchAccount: () => (send) => {
54 return getAccount()
55 .then(function (account) {
56 send({ type: "REPORT_ACCOUNT_PRESENT", account })
57 })
58 .catch(function (err) {
59 send("REPORT_ACCOUNT_MISSING")
60 })
61 },
62 },
63 }
64)
65
66function getAccount() {
67 return Promise.resolve("THE ACCOUNT")
68}

Thanks for reading until here!, if you have any comments or feedback please reach out via twitter!

© 2022 by Horacio Herrera. All rights reserved.
Theme by LekoArts