DEV Community

Joe Purnell
Joe Purnell

Posted on

Create your own State Machine

As a (mostly) self-taught software engineer, there are times where I feel like are gaps in my understanding. Especially around computer science topics. So when I noticed more talk about state machines (namely XState), I chose to learn what they are.

What better way to learn state machines than to try and build one, so let's give it a shot.

If you fancy coding along, check out this codesandbox where you'll find the solution I went towards, and a starting point to implement your own.

What are we doing?

For this exercise, I wanted to take a component has a bit of state logic and updates, and change it to use a state machine. I decided on a simple text input which checks if an email is available (like you see in a sign-up form).

animation of an email being rejected by a form

So, we have our state which tells the component whether to show loading, error, or success messages. We also have an onClick and simulated server communication that changes the state.

So what is a state machine?

If you start reading up about state machines you'll probably hit Wikipedia first (I mean, why not). There you'll start reading about computational models and such. After getting my head around the concept, it seems that you can break it down quick nicely:

An app (or component) that can only be in one of a finite number of statuses at a time is a state machine.

Our email checker shouldn't have more than one status. We shouldn't be in both 'loading' and 'success' states. But we need to be able to transition between states. If we make our transitions via a rigid structure, we can better control the state changes reducing possible bugs and errors.

Creating states

Getting started, let's look at the state we use:

    const [showError, setShowError] = useState(false);
    const [errorMessage, setErrorMessage] = useState("");
    const [showSuccess, setShowSuccess] = useState(false);
    const [loading, setLoading] = useState(false);
    const [inputValue, setInputValue] = useState("");

As this is a somewhat simple component we're creating, our state types reflect that:

    const states = {
      IDLE: "IDLE",
      CHECKING_EMAIL: "CHECKING_EMAIL",
      SUCCESS: "SUCCESS",
      FAILURE: "FAILURE"
    };

Now we have our state types defined, we can reduce our state declaration:

    const [appState, transition] = useReducer(stateMachine,
        {
            state: states.IDLE,
            message: ""
        }
    );
    const [inputValue, setInputValue] = useState("");

We've removed the multiple values used to manage our element and replaced it with a single object that holds our state along with any related information (such as error messages).

With our state types defined, we can adjust our returned component to read from our new singular state:

    if (appState.state === states.SUCCESS) {
        return (
          <div className="App">
            <div className="container successContainer">
              <p className="messagetext successtext">Success! Email free to use.</p>
              <button
                className="button"
                onClick={() => {
                  transition({ type: states.IDLE });
                  setInputValue("");
                }}
              >
                Reset
              </button>
            </div>
          </div>
        );
      }

      return (
        <div className="App">
          <div className="container">
            {appState.state === states.FAILURE && (
              <p className="messagetext errortext">{appState.message}</p>
            )}
            {appState.state === states.CHECKING_EMAIL && (
              <p className="messagetext">Checking email...</p>
            )}
            <input
              className="input"
              placeholder="User Email"
              disabled={
                appState.state !== states.IDLE && appState.state !== states.FAILURE
              }
              value={inputValue}
              onChange={onInputChange}
            />
            <button
              className="button"
              disabled={
                appState.state !== states.IDLE && appState.state !== states.FAILURE
              }
              onClick={() => {
                checkEmail(inputValue);
              }}
            >
              Check Email
            </button>
          </div>
        </div>
      );

The biggest change here is the reduction of multiple checks. Such as no longer needing to check we're still loading when we have an error and want to show the error message.

Transitioning between states

So now we have our new state types, we also have somewhere to house our state, and we have improved our rendering to use the singular state. It's time to get into the meat of our state machine: the transitioning logic.

The transition logic of a state machine has a straight forward pattern which follows this structure:

If I am in status X and I need to transition to status Y I need to perform Z

For example: if I am 'IDLE' and I need to transition to 'CHECKING_EMAIL' all I need to do is set the status to 'CHECKING_EMAIL'.

We then implement this in code like so:

    switch (currentState) {
      case states.IDLE:
        switch (event.nextState) {
          case states.CHECKING_EMAIL:
            nextState = states.CHECKING_EMAIL;
            return nextState;
          default:
            return currentState;
        }
      default:
        return currentState;
    }

Nothing too complicated, just a couple of switch statements is all we need. It also looks like a reducer (if you've had prior experience with Redux or useReducer), this is why it makes sense to use it with the useReducer hook as we saw earlier.

    const [appState, transition] = useReducer(stateMachine, {
      state: states.IDLE,
      message: "",
    });

So how do we handle this second piece of state - the message? Let's look at what happens when we have an error while checking an email address:

    switch (currentState) {
      ...
      case states.CHECKING_EMAIL:
        switch (event.nextState) {
          ...
          case states.FAILURE:
            nextState.message = event.payload.errorMessage;
            nextState.state = states.FAILURE;
            return nextState;
          ...
        }
      ...
      case states.FAILURE:
        switch (nextState) {
          ...
          case states.CHECKING_EMAIL:
            nextState.message = "";
            nextState.state = states.CHECKING_EMAIL;
            return nextState;
          ...
        }
      ...
    }

When we transition from a 'CHECKING_EMAIL' state into 'FAILURE', we can tell our state machine to post the given payload into the state of the component. The reverse is here too - we know when we transition from a 'FAILURE' state back to 'CHECK_EMAIL', we should reset the message field which is what we do.

By protecting our state by only updating through our state machine, we reduce updates and potential bugs that can occur. We also can better trust we only display the correct fields when they're needed.

Triggering state changes

Now we have declared our states and handled transitions we need to look at triggering state changes. Thanks to the previous work we've already done, triggering state changes are super simple. Let's create an onClick handler for our email checking logic. Remember how we declared our state machine using the useReducer hook?

    export default function App() {
      ...
      const [appState, transition] = useReducer(stateMachine, {
        state: states.IDLE,
        message: ""
      });
      ...
    }

We can now call the dispatch return from our useReducer declaration whenever we want to trigger a state transition. These dispatch calls can even include any extra data we might need, such as error messages.

    // Plain state change
    transition({ type: NEW_STATE });

    // State change with a message
    transition({
      type: NEW_STATE,
      payload: { errorMessage: ERROR_MESSAGE }
    });

We can see this in action in our onClick handler for the 'Check Email' button:

    const checkEmail = async email => {
        // transition to checking state
        transition({ type: states.CHECKING_EMAIL });

        // simulate a (slow) call to a server
        await setTimeout(() => {
          if (email.toLowerCase().includes("joe")) {
            // transition to error state
            transition({
              type: states.FAILURE,
              payload: { errorMessage: "Joe is not allowed an account" }
            });
          } else {
            // transition to success state
            transition({ type: states.SUCCESS });
          }
        }, 3000);
      };

Our first step is to transition into a checking state then we simulate a server call. Depending on the result of the call, (in this case the presence of the word 'joe'), we get an error or success response that we can then reflect in our state by triggering another transition.

Can we handle side effects in our state machine?

Long story short - heck yeah! The previous example of handling the logic and transition in an external handler function is purely the design path I took. Nothing is stopping you doing plucking the processing logic from our email handler and popping it into the state machine reducer.

Then, when you declare a move to a new state, like success or failure, the state machine can call itself with the updated data and return the result.

There is one problem that stops us using a useReducer with this method: since we can only have one return per function, we can't update the state twice (once for loading and again for result).

To counter this, we'd have to extract our state machine from the useReducer hook to a standard function utilising useState to update the component. We should end up with something like the following:

    const stateMachine = (appState, event) => {
      const nextState = { ...appState };

      switch (appState.state) {
        case states.IDLE:
          switch (event.type) {
            case states.CHECKING_EMAIL:
              // transition to loading state
              nextState.state = states.CHECKING_EMAIL;
              setState(nextState); // external state setting

              await setTimeout(() => {
                if (event.payload.email.toLowerCase().includes("joe")) {
                  // transition to error state
                  nextState = stateMachine(nextState, {
                    type: states.FAILURE,
                    payload: { errorMessage: "Joe is not allowed an account" }
                  });
                  setState(nextState); // external state setting
                } else {
                  // transition to success state
                  nextState = stateMachine(nextState, { type: states.SUCCESS });
                  setState(nextState); // external state setting
                }
              }, 3000);

              return;
            default:
              setState(nextState); // external state setting
              return;
          }
        ...
      }
    };

All done

So that's it, we've taken a component and converted it to use a custom (albeit basic) state machine. Now loaded with the knowledge on what it takes to create a state machine, we can comfortably use some of the amazing frameworks, like XState, which takes this logic and makes it simpler and more robust to use.

I hope you enjoyed this custom state machine walkthrough. It ended up longer than expected. I constructed this purely from my understanding so if I'm wrong, get in touch and we can learn together.

Latest comments (0)