DEV Community

Mikey Stengel
Mikey Stengel

Posted on • Edited on

State machine advent: Let the machine handle accessibility for you (18/24)

When designing and building apps, we need to ensure that they are accessible to all people. Among other things, this includes managing focus and the tab order deliberately.

To give a small example of a login form where we'd want to manage focus:

  • No input field is shown initially.
  • Upon clicking a button saying "click here to login":
    • Hide the button
    • Show an input field to enter the email
    • Focus the input field so that one can type their email immediately

Solving one of the most common a11y problems in our apps like focus management can be very complicated and error-prone even with straightforward requirements as the ones above.
In React for example, we usually create a ref and manage local state that tracks whether we have already set the focus or not. We then perform some conditional logic on the variable inside an effect where we ultimately perform the ref.currrent.focus() action to set the focus onto the input field.

Sounds complicated, let's put the words into code. We'll start off by adding some types for a little state machine.

interface LoginStateSchema {
  states: {
    /**
     * In the `idle` state, we'll just show the button
     */
    idle: {};

    /**
     * In the `canLogin` state, we want to show the email input field
     */
    canLogin: {};
  };
}

type LoginEvent = 
 | { type: 'ENABLE_LOGIN' }
 | { type: 'SET_EMAIL', email: string };

interface LoginContext {
  email: string;
}
Enter fullscreen mode Exit fullscreen mode

After having written the types, let's go ahead and implement our state machine.

import { Machine, assign } from 'xstate';

const loginOrIdleMachine = Machine<LoginContext, LoginStateSchema, LoginEvent>({
  id: 'loginOrIdle',
  initial: 'idle',
  context: {
    email: '',
  },
  states: {
    idle: {
      on: {
        'ENABLE_LOGIN': {
          target: 'canLogin',
        },        
      },
    },
    canLogin: {
      on: {
        'SET_EMAIL': {
          actions: assign({
            email: (context, event) => event.email,
          })
        }
      }  
    },
  }  
})
Enter fullscreen mode Exit fullscreen mode

With everything we have learned up until this point, our code to manage the focus of the input field is still pretty verbose:

import React, { Fragment, useRef, useLayouteffect, useState } from 'react';
import { useMachine } from '@xstate/react';

const Login = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [state, send] = useMachine(loginOrIdleMachine);
  const [hasManagedFocus, setHasManagedFocus] = useState<boolean>(false);

  useLayoutEffect(() => {
    if (state.matches('canLogin') && !hasManagedFocus){
      inputRef?.current?.focus();
      setHasManagedFocus(true);
    }
  }, [state, setHasManagedFocus, hasManagedFocus, inputRef])

  return (
    <Fragment>
      {state.matches('idle') && 
        (
          <button onClick={() => void send({type: 'ENABLE_LOGIN'}) }>
            click here to login
          </button>
        )
      }

      <input 
        onChange={e => void send({type: 'SET_EMAIL', email: e.target.value})} 
        hidden={!state.matches('canLogin')}
        placeholder="Enter email"
        value={state.context.email}
        ref={inputRef}
      />
    </Fragment>
  )
}
Enter fullscreen mode Exit fullscreen mode

Not only is the code verbose, if we introduced a state transition from the canLogin state towards the idle state again (e.g a button saying "login later" underneath the input field), the requirements would no longer be fulfilled and we'd also have to call setHasManagedFocus(false) conditionally. The reason why we need to reset the hasManagedFocus variable is because we need to ensure that when transitioning from idle => canLogin => idle => canLogin state, the input field of the second canLogin state transition will be focused as well. I'm sure it is possible to create fully accessible applications by managing local variables, but it can get out of hand really quickly.

Let's quickly determine what can happen if this particular part of our code gets out of hand. Once our code gets too complex, we tend to ship less. Introducing bugs or being too afraid to touch the a11y logic of our app can literally result in us denying people access to our app or service. Needless to say, we have to get this right!

We can simplify our code by reframing the question. Ask: "On which state transition should we perform the action to steal focus?" instead of asking in which states we should and shouldn't steal focus. Sure, when modeling our state machine, we should also think about the latter question but the worry is purely solved within the machine. As a consequence, it removes conditional logic from our code and frees our minds from a lot of cognitive overhead.

Now that we know what kind of question to ask, let's learn about one particular primitive in XState that could help us find a good answer. Each state node in XState has an entry property. We can call actions by their name as a value of the property and they'll be executed every time the invoked machine enters (or reenters) the state node.
To conclude our quest for the best place to put our focus action: We want to focus the input field right after entering the canLogin state.

canLogin: {
  // focusInput action will be called whenever the machine enters the state node 
  entry: 'focusInput',
  on: {
    'SET_EMAIL': {
      actions: assign({
        email: (context, event) => event.email,
      })
    }
  }  
},
Enter fullscreen mode Exit fullscreen mode

Pretty rad but how can our machine define the focusInput action when it doesn't know anything about the input element or the DOM to begin with?
The machine doesn't define the action. Instead, the action will be defined within our React component and passed into the machine as a configuration.

const [state, send] = useMachine(loginOrIdleMachine.withConfig({
  actions: {
    'focusInput': () => void inputRef?.current?.focus(),
  }
}));
Enter fullscreen mode Exit fullscreen mode

That's right, components can pass actions and guards inside a configuration object (withConfig) into the machine. We can then call the guard or action by their distinct name (e.g entry: 'focusInput'); hence, handle focus management within the state machine. 🎉🥳

Once we put everything together, our code could look like this:

import React, { useRef, Fragment } from 'react';
import { useMachine } from '@xstate/react';
import { Machine, assign } from 'xstate';

/** Type declarations  */
interface LoginStateSchema {
  states: {
    idle: {};
    canLogin: {};
  };
}

type LoginEvent =
 | { type: 'ENABLE_LOGIN' }
 | { type: 'SET_EMAIL'; email: string };

interface LoginContext {
  email: string;
}

/** Machine implementation  */
const loginOrIdleMachine = Machine<LoginContext, LoginStateSchema, LoginEvent>({
  id: 'loginOrIdle',
  initial: 'idle',
  context: {
    email: '',
  },
  states: {
    idle: {
      on: {
        'ENABLE_LOGIN': {
          target: 'canLogin',
        },
      },
    },
    canLogin: {
      entry: 'focusInput',
      on: {
        'SET_EMAIL': {
          actions: assign({
            email: (context, event) => event.email,
          }),
        },
      },
    },
  },
});


/** Component that invokes the machine  */
const Login = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [state, send] = useMachine(loginOrIdleMachine.withConfig({
    actions: {
      'focusInput': () => void inputRef?.current?.focus(),
    },
  }));

  return (
    <Fragment>
      {state.matches('idle') &&
        (
          <button onClick={() => void send({type: 'ENABLE_LOGIN'}) }>
            click here to log in
          </button>
        )
      }

      <input
        onChange={e => void send({type: 'SET_EMAIL', email: e.target.value})}
        hidden={!state.matches('canLogin')}
        placeholder="Enter email"
        value={state.context.email}
        ref={inputRef}
      />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Awesome! We have eliminated most of the local state (except the ref which we always need for focusing) and have refactored the React effect to an action that is called by our machine. Even if we were to introduce some more states, or a button + event to transition back to the initial idle state as described above, the input field will always be focused when our machine enters the canLogin state.

I hope this post gave you some insights on how to pass actions to a machine and also showcased the accessibility benefits by doing so.

About this series

Throughout the first 24 days of December, I'll publish a small blog post each day teaching you about the ins and outs of state machines and statecharts.

The first couple of days will be spent on the fundamentals before we'll progress to more advanced concepts.

Top comments (2)

Collapse
 
mojitane profile image
moji ///

Hi, really enjoing this series getting into xstate. Thank you so much for the fantastic articles and real world use cases that aren't just super simple examples.
One question that's only tangentially related. I saw you are using a lot of "void" with your arrow functions. I know void is used to return undefined but I don't understand the benefit in the samples. Just to ensure there is no return?

Collapse
 
codingdive profile image
Mikey Stengel • Edited

Hey, thank you for reaching out. I'm glad you find them useful.

It's just a code preference and helps me think in events. One UI event handler = one event sent to the machine and possibly multiple actions, validations and side effects within the machine.

You can't ever do something like

<input onChange={(event) => void event.target.value && send({ type: 'WRITE', input: event.target.value}) /> 

As void true still yields undefined. Therefore, the event would never be sent. This forces me to write all the conditional code into the machine and keep as little logic in the UI as possible. 😊