Using React and XState to Build a Sign In Form

To make a sign in form with good UX requires UI state management, meaning we’d like to minimize the cognitive load to complete it and reduce the number of required user actions while making an intuitive experience. Think about it: even a relatively simple email and password sign in form needs to handle a number of different states, like empty fields, errors, password requirements, loading and success.

Thankfully, state management is what React was made for and I was able to create a sign in form with it using an approach that features XState, a JavaScript state management library using finite machines.

State management? Finite machines? We’re going to walk through these concepts together while putting together a solid sign in form.

Jumping ahead, here’s what we’re going to build together:

First, let’s set up

We’ll need a few tools before getting started. Here’s what to grab:

Once those are in hand, we can make sure our project folder is set up for development. Here’s an outline of how the files should be structured:

public/ |--src/ |--Loader/ |--SignIn/ |--contactAuthService.js |--index.jsx |--isPasswordShort.js |--machineConfig.js |--styles.js |--globalStyles.js |--index.jsx
package.json

A little background on XState

We already mentioned that XState is a state management JavaScript library. Its approach uses finite state machines which makes it ideal for this sort of project. For example:

  • It is a thoroughly tried and tested approach to state management. Finite state machines have been around for 30+ years.
  • It is built accordance to specification.
  • It allows logic to be completely separated from implementation, making it easily testable and modular.
  • It has a visual interpreter which gives great feedback of what’s been coded and makes communicating the system to another person that much easier.

For more information on finite-state machines check out David Khourshid’s article.

Machine Config

The machine config is the core of XState. It is a statechart and it will define the logic of our form. I have broken it down into the following parts, which we’ll go over one by one.

1. The States

We need a way to control what to show, hide, enable and disable. We will control this using named-states, which include:

dataEntry: This is the state when the user can enter an email and password into the provided fields. We can consider this the default state. The current field will be highlighted in blue.

awaitingResponse: This is after the browser makes a request to the authentication service and we are waiting for the response. We’ll disable the form and replace the button with a loading indicator when the form is in this state.

emailErr: Whoops! This state is thrown when there is a problem with the email address the user has entered. We’ll highlight that field, display the error, and disable the other field and button.

passwordErr: This is another error state, this time when there is a problem with the password the user has entered. Like the previous error, we’ll highlight the field, display the error, and disable the rest of the form.

serviceErr: We reach this state when we are unable contact the authentication service, preventing the submitted data to be checked. We’ll display an error along with a “Retry” button to re-attempt a service connection.

signedIn: Success! This is when the user has successfully authenticated and proceeds past the sign in form. Normally, this would take the user to some view, but we’ll simply confirm authentication since we’re focusing solely on the form.

See the machinConfig.js file in the SignIn directory? Crack that open so we can define our states. We list them as properties of a states object. We also need to define an initial state, which mentioned earlier, will be the dataEntry state, allowing the user to enter data into the form fields.

const machineConfig = { id: 'signIn', initial: 'dataEntry', states: { dataEntry: {}, awaitingResponse: {}, emailErr: {}, passwordErr: {}, serviceErr: {}, signedIn: {}, }
} export default machineConfig

Each part of this article will show the code of machineConfig.js along with a diagram produced from the code using XState’s visualizer.

2. The Transitions

Now that we have defined our states, we need to define how to change from one state to another and, in XState, we do that with a type of event called a transition. We define transitions within each state. For example, If the ENTER_EMAIL transition is triggered when we’re in the emailErr state, then the system will move to state dataEntry.

emailErr: { on: { ENTER_EMAIL: { target: 'dataEntry' } }
}

Note that nothing would happen if a different type of transition was triggered (such as ENTER_PASSWORD) while in the emailErr state. Only transitions that are defined within the state are valid.

When a transition has no target, it is an external (by default) self-transition. When triggered, the state will exit and re-enter itself. As an example, the machine will change from dataEntry back to dataEntry when the ENTER_EMAIL transition is triggered.

Here’s how that is defined:

dataEntry: { on: { ENTER_EMAIL: {} }
}

Sounds weird, I know, but we’ll explain it a little later. Here’s the machineConfig.js file so far.

const machineConfig = { id: 'signIn', initial: 'dataEntry', states: { dataEntry: { on: { ENTER_EMAIL: {}, ENTER_PASSWORD: {}, EMAIL_BLUR: {}, PASSWORD_BLUR: {}, SUBMIT: { target: 'awaitingResponse', }, }, }, awaitingResponse: {}, emailErr: { on: { ENTER_EMAIL: { target: 'dataEntry', }, }, }, passwordErr: { on: { ENTER_PASSWORD: { target: 'dataEntry', }, }, }, serviceErr: { on: { SUBMIT: { target: 'awaitingResponse', }, }, }, signedIn: {}, },
}; export default machineConfig;

3. Context

We need a way to save what the user enters into the input fields. We can do that in XState with context, which is an object within the machine that enables us to store data. So, we’ll need to define that in our file as well.

Email and password are both empty strings by default. When the user enters their email or password, this is where we’ll store it.

const machineConfig = { id: 'signIn', context: { email: '', password: '', }, ...

4. Hierarchical States

We will need a way to be more specific about our errors. Instead of simply telling the user there is an email error, we need to tell them what kind of error happened. Perhaps it’s email with the wrong format or there is no account linked to the entered email — we should let the user know so there’s no guessing. This is where we can use hierarchical states which are essentially state machines within state machines. So, instead of having a emailErr state, we can add sub-states, such as emailErr.badFormat or emailErr.noAccount.

For the emailErr state, we have defined two sub-states: badFormat and noAccount. This means the machine can no longer only be in the emailErr state; it would be either in the emailErr.badFormat state or the emailErr.noAccount state and having them parsed out allows us to provide more context to the user in the form of unique messaging in each sub-state.

const machineConfig = { ... states: { ... emailErr: { on: { ENTER_EMAIL: { target: 'dataEntry', }, }, initial: 'badFormat', states: { badFormat: {}, noAccount: {}, }, }, passwordErr: { on: { ENTER_PASSWORD: { target: 'dataEntry', }, }, initial: 'tooShort', states: { tooShort: {}, incorrect: {}, }, }, ...

5. Guards

When the user blurs an input or clicks submit, we need to check if the email and/or password are valid. If even one of those values is in a bad format, we need to prompt the user to change it. Guards allows us to transition to a state depending on those types of conditions.

Here, we’re using the EMAIL_BLUR transition to change the state to emailErr.badFormat only if the condition isBadEmailFormat returns true. We are doing a similar thing to PASSWORD_BLUR.

We’re also changing the SUBMIT transition’s value to an array of objects with a target and condition property. When the SUBMIT transition is triggered, the machine will go through each of the conditions, from first to last, and change the state of the first condition that returns true. For example, if isBadEmailFormat returns true, the machine will change to state emailErr.badFormat. However, if isBadEmailFormat returns false, the machine will move to the next condition statement and check if it returns true.

const machineConfig = { ... states: { ... dataEntry: { ... on: { EMAIL_BLUR: { cond: 'isBadEmailFormat', target: 'emailErr.badFormat' }, PASSWORD_BLUR: { cond: 'isPasswordShort', target: 'passwordErr.tooShort' }, SUBMIT: [ { cond: 'isBadEmailFormat', target: 'emailErr.badFormat' }, { cond: 'isPasswordShort', target: 'passwordErr.tooShort' }, { target: 'awaitingResponse' } ], ...

6. Invoke

All of the work we’ve done so far would be for nought if we didn’t make a request to an authentication service. The result of what’s entered and submitted to the form will inform many of the states we defined. So, invoking that request should result in one of two states:

  • Transition to the signedIn state if it returns successfully, or
  • transition to one of our error states if it fails.

The invoke method allows us to declare a promise and transition to different states, depending on what that promise returns. The src property takes a function that has two parameters: context and event (but we’re only using context here). We return a promise (our authentication request) with the values of email and password from the context. If the promise returns successfully, we will transition to the state defined in the onDone property. If an error is returned, we will transition to the state defined in the onError property.

const machineConfig = { ... states: { ... // We’re in a state of waiting for a response awaitingResponse: { // Make a call to the authentication service invoke: { src: 'requestSignIn', // If successful, move to the signedIn state onDone: { target: 'signedIn' }, // If email input is unsuccessful, move to the emailErr.noAccount sub-state onError: [ { cond: 'isNoAccount', target: 'emailErr.noAccount' }, { // If password input is unsuccessful, move to the passwordErr.incorrect sub-state cond: 'isIncorrectPassword', target: 'passwordErr.incorrect' }, { // If the service itself cannot be reached, move to the serviceErr state cond: 'isServiceErr', target: 'serviceErr' } ] }, }, ...

7. Actions

We need a way to save what the user enters into the email and password fields. Actions enable side effects to be triggered when a transition occurs. Below, we have defined an action (cacheEmail) within the ENTER_EMAIL transition of the dataEntry state. This means if the machine is in dataEntry and the transition ENTER_EMAIL is triggered, the action cacheEmail will also be triggered.

const machineConfig = { ... states: { ... // On submit, target the two fields dataEntry: { on: { ENTER_EMAIL: { actions: 'cacheEmail' }, ENTER_PASSWORD: { actions: 'cachePassword' }, }, ... }, // If there’s an email error on that field, trigger email cache action emailErr: { on: { ENTER_EMAIL: { actions: 'cacheEmail', ... } } }, // If there’s a password error on that field, trigger password cache action passwordErr: { on: { ENTER_PASSWORD: { actions: 'cachePassword', ... } } }, ...

8. Final State

We need to way to indicate if the user has successfully authenticated and, depending on the result, trigger the next stage of the user journey. Two things are required for this:

  • We declare that one of the states is the final state, and
  • define an onDone property that can trigger actions when that final state is reached.

Within the signedIn state, we add type: final. We also add an onDone property with action onAuthentication. Now, when the state signedIn is reached, the action onAuthentication will be triggered and the machine will be done (no longer executable).

const machineConfig = { ... states: { ... signedIn: { type: 'final' }, onDone: { actions: 'onAuthentication' }, ...

9. Test

A great feature of XState is that the machine configuration is completely independent of the actual implementation. This means we can test it now and get confidence with what we’ve made before connecting it to the UI and backend service. We can copy and paste the machine config file into XState’s visualizer and get a auto-generated statechart diagram that not only outlines all the defined states with arrows that illustrate how they’re all connected, but allows us to interact with the chart as well. This is built-in testing!

Connecting the machine to a React component

Now that we’ve written our statechart, it’s time to connect it to our UI and backend service. An XState machine options object allows us to map strings we declared in the config to functions.

We’ll begin by defining a React class component with three refs:

// SignIn/index.jsx import React, { Component, createRef } from 'react' class SignIn extends Component { emailInputRef = createRef() passwordInputRef = createRef() submitBtnRef = createRef() render() { return null }
} export default SignIn

Map out the actions

We declared the following actions in our machine config:

  • focusEmailInput
  • focusPasswordInput
  • focusSubmitBtn
  • cacheEmail
  • cachePassword
  • onAuthentication

Actions are mapped in the machine config’s actions property. Each function takes two arguments: context (ctx) and event (evt).

focusEmailInput and focusPasswordInput are pretty straightforward, however, there is a bug. These elements are being focused when coming from a disabled state. The function to focus these elements is firing right before the elements are re-enabled. The delay function gets around that.

cacheEmail and cachePassword need to update the context. To do this, we use the assign function (provided by XState). Whatever is returned by the assign function is added to our context. In our case, it is reading the input’s value from the event object and then adding that value to the context’s email or password. From there property.assign is added to the context. Again, in our case, it is reading the input’s value from the event object and adding that value to the context’s email or password property.

// SignIn/index.jsx import { actions } from 'xstate'
const { assign } = actions const delay = func => setTimeout(() => func()) class SignIn extends Component { ... machineOptions = { actions: { focusEmailInput: () => { delay(this.emailInputRef.current.focus()) }, focusPasswordInput: () => { delay(this.passwordInputRef.current.focus()) }, focusSubmitBtn: () => { delay(this.submitBtnRef.current.focus()) }, cacheEmail: assign((ctx, evt) => ({ email: evt.value })), cachePassword: assign((ctx, evt) => ({ password: evt.value })), // We’ll log a note in the console to confirm authentication onAuthentication: () => { console.log('user authenticated') } }, }
}

Put up our guards

We declared the following guards in our machine config:

  • isBadEmailFormat
  • isPasswordShort
  • isNoAccount
  • isIncorrectPassword
  • isServiceErr

Guards are mapped in the machine configuration’s guards property. The isBadEmailFormat and isPasswordShort guards make use of the context to read the email and password entered by the user then pass them on to the appropriate functions. isNowAccount, isIncorrectPassword and isServiceErr make use of the event object to read what kind of error was returned from the call to the authentication service.

// isPasswordShort.js const isPasswordShort = password => password.length < 6 export default isPasswordShort
// SignIn/index.jsx import { isEmail } from 'validator'
import isPasswordShort from './isPasswordShort' class SignIn extends Component { ... machineOptions = { ... guards: { isBadEmailFormat: ctx => !isEmail(ctx.email), isPasswordShort: ctx => isPasswordShort(ctx.password), isNoAccount: (ctx, evt) => evt.data.code === 1, isIncorrectPassword: (ctx, evt) => evt.data.code === 2, isServiceErr: (ctx, evt) => evt.data.code === 3 }, }, ...
}

Hook up the services

We declared the following service in our machine configuration (within our invoke definition): requestSignIn.

Services are mapped in the machine configuration’s services property. In this case, the function is a promise and is passed to the email password from the context.

// contactAuthService.js
// error code 1 - no account
// error code 2 - wrong password
// error code 3 - no response const isSuccess = () => Math.random() >= 0.8
const generateErrCode = () => Math.floor(Math.random() * 3) + 1 const contactAuthService = (email, password) => new Promise((resolve, reject) => { console.log(`email: ${email}`) console.log(`password: ${password}`) setTimeout(() => { if (isSuccess()) resolve() reject({ code: generateErrCode() }) }, 1500)
}) export default contactAuthService
// SignIn/index.jsx
...
import contactAuthService from './contactAuthService.js' class SignIn extends Component { ... machineOptions = { ... services: { requestSignIn: ctx => contactAuthService(ctx.email, ctx.password) } }, ...
}

react-xstate-js connects React and XState

Now that we have our machine config and options at the ready, we can create the actual machine! In order to use XState in a real world scenario, that requires an interpreter. react-xstate-js is an interpreter that connects React with XState using the render props approach. (Full disclosure, I developed this library.) It takes two props — config and options — and returns an XState service and state object.

// SignIn/index.jsx
...
import { Machine } from 'react-xstate-js'
import machineConfig from './machineConfig' class SignIn extends Component { ... render() { <Machine config={machineConfig} options={this.machineOptions}> {({ service, state }) => null} </Machine> }
}

Let’s make the UI!

OK, we have a functional machine but the user needs to see the form in order to use it. That means it’s time to create the markup for the UI component. There are two things we need to do to communicate with our machine:

1. Read the state

To determine what state we are in, we can use the state’s matches method and return a boolean. For example: state.matches('dataEntry').

2. Fire a transition

To fire a transition, we use the service’s send method. It takes an object with the transitions type we want to trigger as well as any other key and value pairs we want in the evt object. For example: service.send({ type: 'SUBMIT' }).

// SignIn/index.jsx ...
import { Form, H1, Label, Recede, Input, ErrMsg, Button, Authenticated, MetaWrapper, Pre
} from './styles' class SignIn extends Component { ... render() { <Machine config={machineConfig} options={this.machineOptions}> {({ service, state }) => { const disableEmail = state.matches('passwordErr') || state.matches('awaitingResponse') || state.matches('serviceErr') const disablePassword = state.matches('emailErr') || state.matches('awaitingResponse') || state.matches('serviceErr') const disableSubmit = state.matches('emailErr') || state.matches('passwordErr') || state.matches('awaitingResponse') const fadeHeading = state.matches('emailErr') || state.matches('passwordErr') || state.matches('awaitingResponse') || state.matches('serviceErr') return ( <Form onSubmit={e => { e.preventDefault() service.send({ type: 'SUBMIT' }) }} noValidate > <H1 fade={fadeHeading}>Welcome Back</H1> <Label htmlFor="email" disabled={disableEmail}> email </Label> <Input id="email" type="email" placeholder="charlie@gmail.com" onBlur={() => { service.send({ type: 'EMAIL_BLUR' }) }} value={state.context.email} err={state.matches('emailErr')} disabled={disableEmail} onChange={e => { service.send({ type: 'ENTER_EMAIL', value: e.target.value }) }} ref={this.emailInputRef} autoFocus /> <ErrMsg> {state.matches({ emailErr: 'badFormat' }) && "email format doesn't look right"} {state.matches({ emailErr: 'noAccount' }) && 'no account linked with this email'} </ErrMsg> <Label htmlFor="password" disabled={disablePassword}> password <Recede>(min. 6 characters)</Recede> </Label> <Input id="password" type="password" placeholder="Passw0rd!" value={state.context.password} err={state.matches('passwordErr')} disabled={disablePassword} onBlur={() => { service.send({ type: 'PASSWORD_BLUR' }) }} onChange={e => { service.send({ type: 'ENTER_PASSWORD', value: e.target.value }) }} ref={this.passwordInputRef} /> <ErrMsg> {state.matches({ passwordErr: 'tooShort' }) && 'password too short (min. 6 characters)'} {state.matches({ passwordErr: 'incorrect' }) && 'incorrect password'} </ErrMsg> <Button type="submit" disabled={disableSubmit} loading={state.matches('awaitingResponse')} ref={this.submitBtnRef} > {state.matches('awaitingResponse') && ( <> loading <Loader /> </> )} {state.matches('serviceErr') && 'retry'} {!state.matches('awaitingResponse') && !state.matches('serviceErr') && 'sign in' } </Button> <ErrMsg> {state.matches('serviceErr') && 'problem contacting server'} </ErrMsg> {state.matches('signedIn') && ( <Authenticated> <H1>authenticated</H1> </Authenticated> )} </Form> ) }} </Machine> }
}

We have a form!

And there you have it. A sign in form that has a great user experience controlled by XState. Not only were we able to create a form a user can interact with, we also put a lot of thought into the many states and types of interactions that’s need to be considered, which is a good exercise for any piece of functionality that would go into a component.

Hit up the comments form if there’s something that doesn’t make sense or if there’s something else you think might need to be considered in the form. Would love to hear your thoughts!

More resources

The post Using React and XState to Build a Sign In Form appeared first on CSS-Tricks.