Tag: XState

Coordinating Svelte Animations With XState

This post is an introduction to XState as it might be used in a Svelte project. XState is unique in the JavaScript ecosystem. It doesn’t keep your DOM synced with your application state, nor does it help you with asynchrony, or streams of data; XState helps manage your application’s state by allowing you to model your state as a finite state machine (FSM).

A deep dive into state machines and formal languages is beyond the scope of this post, but Jon Bellah does that in another CSS-Tricks article. For now, think of an FSM as a flow chart. Flow charts have a number of states, represented as bubbles, and arrows leading from one state to the next, signifying a transition from one state to the next. State machines can have more than one arrow leading out of a state, or none at all if it’s a final state, and they can even have arrows leaving a state, and pointing right back into that same state.

If that all sounds overwhelming, relax, we’ll get into all the details, nice and slow. For now, the high level view is that, when we model our application as a state machine, we’ll be creating different “states” our application can be in (get it … state machine … states?), and the events that happen and cause changes to state will be the arrows between those states. XState calls the states “states,” and the arrows between the states “actions.”

Our example

XState has a learning curve, which makes it challenging to teach. With too contrived a use case it’ll appear needlessly complex. It’s only when an application’s code gets a bit tangled that XState shines. This makes writing about it tricky. With that said, the example we’ll look at is an autocomplete widget (sometimes called autosuggest), or an input box that, when clicked, reveals a list of items to choose from, which filter as you type in the input.

For this post we’ll look at getting the animation code cleaned up. Here’s the starting point:

This is actual code from my svelte-helpers library, though with unnecessary pieces removed for this post. You can click the input and filter the items, but you won’t be able to select anything, “arrow down” through the items, hover, etc. I’ve removed all the code that’s irrelevant to this post.

We’ll be looking at the animation of the list of items. When you click the input, and the results list first renders, we want to animate it down. As you type and filter, changes to the list’s dimensions will animate larger and smaller. And when the input loses focus, or you click ESC, we animate the list’s height to zero, while fading it out, and then remove it from the DOM (and not before). To make things more interesting (and nice for the user), let’s use a different spring configuration for the opening than what we use for the closing, so the list closes a bit more quickly, or stiffly, so unneeded UX doesn’t linger on the screen too long.

If you’re wondering why I’m not using Svelte transitions to manage the animations in and out of the DOM, it’s because I’m also animating the list’s dimensions when it’s open, as the user filters, and coordinating between transition, and regular spring animations is a lot harder than simply waiting for a spring update to finish getting to zero before removing an element from the DOM. For example, what happens if the user quickly types and filters the list, as it’s animating in? As we’ll see, XState makes tricky state transitions like this easy.

Scoping the Problem

Let’s take a look at the code from the example so far. We’ve got an open variable to control when the list is open, and a resultsListVisible property to control whether it should be in the DOM. We also have a closing variable that controls whether the list is in the process of closing.

On line 28, there’s an inputEngaged method that runs when the input is clicked or focused. For now let’s just note that it sets open and resultsListVisible to true. inputChanged is called when the user types in the input, and sets open to true. This is for when the input is focused, the user clicks escape to close it, but then starts typing, so it can re-open. And, of course, the inputBlurred function runs when you’d expect, and sets closing to true, and open to false.

Let’s pick apart this tangled mess and see how the animations work. Note the slideInSpring and opacitySpring at the top. The former slides the list up and down, and adjusts the size as the user types. The latter fades the list out when hidden. We’ll focus mostly on the slideInSpring.

Take a look at the monstrosity of a function called setSpringDimensions. This updates our slide spring. Focusing on the important pieces, we take a few boolean properties. If the list is opening, we set the opening spring config, we immediately set the list’s width (I want the list to only slide down, not down and out), via the { hard: true } config, and then set the height. If we’re closing, we animate to zero, and, when the animation is complete, we set resultsListVisible to false (if the closing animation is interrupted, Svelte will be smart enough to not resolve the promise so the callback will never run). Lastly, this method is also called any time the size of the results list changes, i.e., as the user filters. We set up a ResizeObserver elsewhere to manage this.

Spaghetti galore

Let’s take stock of this code.

  • We have our open variable which tracks if the list is open.
  • We have the resultsListVisible variable which tracks if the list should be in the DOM (and set to false after the close animation is complete).
  • We have the closing variable that tracks if the list is in the process of closing, which we check for in the input focus/click handler so we can reverse the closing animation if the user quickly re-engages the widget before it’s done closing.
  • We also have setSpringDimensions that we call in four different places. It sets our springs depending on whether the list is opening, closing, or just resizing while open (i.e. if the user filters the list).
  • Lastly, we have a resultsListRendered Svelte action that runs when the results list DOM element renders. It starts up our ResizeObserver, and when the DOM node unmounts, sets closing to false.

Did you catch the bug? When the ESC button is pressed, I’m only setting open to false. I forgot to set closing to true, and call setSpringDimensions(false, true). This bug was not purposefully contrived for this blog post! That’s an actual mistake I made when I was overhauling this widget’s animations. I could just copy paste the code in inputBlured over to where the escape button is caught, or even move it to a new function and call it from both places. This bug isn’t fundamentally hard to solve, but it does increase the cognitive load of the code.

There’s a lot of things we’re keeping track of, but worst of all, this state is scattered all throughout the module. Take any piece of state described above, and use CodeSandbox’s Find feature to view all the places where that piece of state is used. You’ll see your cursor bouncing across the file. Now imagine you’re new to this code, trying to make sense of it. Think about the growing mental model of all these state pieces that you’ll have to keep track of, figuring out how it works based on all the places it exists. We’ve all been there; it sucks. XState offers a better way; let’s see how.

Introducing XState

Let’s step back a bit. Wouldn’t it be simpler to model our widget in terms of what state it’s in, with events happening as the user interacts, which cause side effects, and transitions to new states? Of course, but that’s what we were already doing; the problem is, the code is scattered everywhere. XState gives us the ability to properly model our state in this way.

Setting expectations

Don’t expect XState to magically make all of our complexity vanish. We still need to coordinate our springs, adjust the spring’s config based on opening and closing states, handle resizes, etc. What XState gives us is the ability to centralize this state management code in a way that’s easy to reason about, and adjust. In fact, our overall line count will increase a bit, as a result of our state machine setup. Let’s take a look.

Your first state machine

Let’s jump right in, and see what a bare bones state machine looks like. I’m using XState’s FSM package, which is a minimal, pared down version of XState, with a tiny 1KB bundle size, perfect for libraries (like an autosuggest widget). It doesn’t have a lot of advanced features like the full XState package, but we wouldn’t need them for our use case, and we wouldn’t want them for an introductory post like this.

The code for our state machine is below, and the interactive demo is over at Code Sandbox. There’s a lot, but we’ll go over it shortly. And to be clear, it doesn’t work yet.

const stateMachine = createMachine(   {     initial: "initial",     context: {       open: false,       node: null     },     states: {       initial: {         on: { OPEN: "open" }       },       open: {         on: {           RENDERED: { actions: "rendered" },           RESIZE: { actions: "resize" },           CLOSE: "closing"         },         entry: "opened"       },       closing: {         on: {           OPEN: { target: "open", actions: ["resize"] },           CLOSED: "closed"         },         entry: "close"       },       closed: {         on: {           OPEN: "open"         },         entry: "closed"       }     }   },   {     actions: {       opened: assign(context => {         return { ...context, open: true };       }),       rendered: assign((context, evt) => {         const { node } = evt;         return { ...context, node };       }),       close() {},       resize(context) {},       closed: assign(() => {         return { open: false, node: null };       })     }   } );

Let’s go from top to bottom. The initial property controls what the initial state is, which I’ve called “initial.” context is the data associated with our state machine. I’m storing a boolean for whether the results list is currently open, as well as a node object for that same results list. Next we see our states. Each state is a key in the states property. For most states, you can see we have an on property, and an entry property.

on configures events. For each event, we can transition to a new state; we can run side effects, called actions; or both. For example, when the OPEN event happens inside of the initial state, we move into the open state. When the RENDERED event happens in the open state, we run the rendered action. And when the OPEN event happens inside the closing state, we transition into the open state, and also run the resize action. The entry field you see on most states configures an action to run automatically whenever a state is entered. There are also exit actions, although we don’t need them here.

We still have a few more things to cover. Let’s look at how our state machine’s data, or context, can change. When we want an action to modify context, we wrap it in assign and return the new context from our action; if we don’t need any processing, we can just pass the new state directly to assign. If our action does not update context, i.e., it’s just for side effects, then we don’t wrap our action function in assign, and just perform whatever side effects we need.

Affecting change in our state machine

We have a cool model for our state machine, but how do we run it? We use the interpret function.

const stateMachineService = interpret(stateMachine).start();

Now stateMachineService is our running state machine, on which we can invoke events to force our transitions and actions. To fire an event, we call send, passing the event name, and then, optionally, the event object. For example, in our Svelte action that runs when the results list first mounts in the DOM, we have this:

stateMachineService.send({ type: "RENDERED", node });

That’s how the rendered action gets the node for the results list. If you look around the rest of the AutoComplete.svelte file, you’ll see all the ad hoc state management code replaced with single line event dispatches. In the event handler for our input click/focus, we run the OPEN event. Our ResizeObserver fires the RESIZE event. And so on.

Let’s pause for a moment and appreciate the things XState gives us for free here. Let’s look at the handler that runs when our input is clicked or focused before we added XState.

function inputEngaged(evt) {   if (closing) {     setSpringDimensions();   }   open = true;   resultsListVisible = true; } 

Before, we were checking to see if we were closing, and if so, forcing a re-calculation of our sliding spring. Otherwise we opened our widget. But what happened if we clicked on the input when it was already open? The same code re-ran. Fortunately that didn’t really matter. Svelte doesn’t care if we re-set open and resultsListVisible to the values they already held. But those concerns disappear with XState. The new version looks like this:

 function inputEngaged(evt) {   stateMachineService.send("OPEN"); }

If our state machine is already in the open state, and we fire the OPEN event, then nothing happens, since there’s no OPEN event configured for that state. And that special handling for when the input is clicked when the results are closing? That’s also handled right in the state machine config — notice how the OPEN event tacks on the resize action when it’s run from the closing state.

And, of course, we’ve fixed the ESC key bug from before. Now, pressing the key simply fires the CLOSE event, and that’s that.

Finishing up

The ending is almost anti-climactic. We need to take all of the work we were doing before, and simply move it to the right place among our actions. XState does not remove the need for us to write code; it only provides a structured, clear place to put it.

{   actions: {     opened: assign({ open: true }),     rendered: assign((context, evt) => {       const { node } = evt;       const dimensions = getResultsListDimensions(node);       itemsHeightObserver.observe(node);       opacitySpring.set(1, { hard: true });       Object.assign(slideInSpring, SLIDE_OPEN);       slideInSpring.update(prev => ({ ...prev, width: dimensions.width }), {         hard: true       });       slideInSpring.set(dimensions, { hard: false });       return { ...context, node };     }),     close() {       opacitySpring.set(0);       Object.assign(slideInSpring, SLIDE_CLOSE);       slideInSpring         .update(prev => ({ ...prev, height: 0 }))         .then(() => {           stateMachineService.send("CLOSED");         });     },     resize(context) {       opacitySpring.set(1);       slideInSpring.set(getResultsListDimensions(context.node));     },     closed: assign(() => {       itemsHeightObserver.unobserve(resultsList);       return { open: false, node: null };     })   } }

Odds and ends

Our animation state is in our state machine, but how do we get it out? We need the open state to control our results list rendering, and, while not used in this demo, the real version of this autosuggest widget needs the results list DOM node for things like scrolling the currently highlighted item into view.

It turns out our stateMachineService has a subscribe method that fires whenever there’s a state change. The callback you pass is invoked with the current state machine state, which includes a context object. But Svelte has a special trick up its sleeve: its reactive syntax of $ : doesn’t only work with component variables and Svelte stores; it also works with any object with a subscribe method. That means we can sync with our state machine with something as simple as this:

$  : ({ open, node: resultsList } = $  stateMachineService.context);

Just a regular destructuring, with some parens to help things get parsed correctly.

One quick note here, as an area for improvement. Right now, we have some actions which both both perform a side effect, and also update state. Ideally, we should probably split these up into two actions, one just for the side effect, and the other using assign for the new state. But I decided to keep things as simple as possible for this article to help ease the introduction of XState, even if a few things wound up not being quite ideal.

Parting thoughts

I hope this post has sparked some interest in XState. I’ve found it to be an incredibly useful, easy to use tool for managing complex state. Please know that we’ve only scratched the surface. We focused on the minimal fsm package, but the entire XState library is capable of a lot more than what we covered here, from nested states, to first-class support for Promises, and it even has a state visualization tool! I urge you to check it out.

Happy coding!


The post Coordinating Svelte Animations With XState appeared first on CSS-Tricks.

You can support CSS-Tricks by being an MVP Supporter.

CSS-Tricks

, , ,

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.

CSS-Tricks

, , , , ,
[Top]