State Machines, Statecharts & Xstate

In this article I would like to introduce and motivate the use of State Machines via ➝ Statecharts notation, implemented through ➝ the Xstate library. This is a somewhat developer-centric introduction, but Statecharts is a powerful tool that is of benefit to everyone involved in creation of software: Project Managers, Designers, Clients etc. If you don't code, just skim through the code parts, and hopefully you can still learn something useful :)

Xstate can Radically simplify our UI components.

Let's dive right into an example:

A Simple Todo App

Somebody did a Design Sprint, and here is the Spec Sheet:

I've implemented this in both "vanilla" React and React + Xstate, we will compare and contrast both.

You can have a quick play around with one of the results here:

Below is the React specific code for both implementations of this example, App.tsx. Please scroll around and compare. I've also provided a link to stackblitz projects, so you can get full insight into them. You can safely hold off checking those out after this article, but choose your own adventure :)

Disclaimer: This is a contrived example of course. I encourage you to imagine how this scales in a real world app, with seperate files, components and more features.

React

          
            import { useEffect, useState } from "react";
            import { dbApi } from "./api";

            function App() {
              const [todos, setTodos] = useState<string[]>([]);
              const [loadingTodos, setLoadingTodos] = useState(false);
              const [refreshTodos, setRefreshTodos] = useState(true);
              const [todo, setTodo] = useState("");
              const [creatingNew, setCreatingNew] = useState(false);
              const [savingTodo, setSavingTodo] = useState(false);
              const [deletingToDo, setDeletingToDo] = useState("");
              const [errorMessage, setErrorMessage] = useState("");

              useEffect(() => {
                if (refreshTodos) {
                  setLoadingTodos(true);
                  dbApi.getTodos().then((result) => {
                    setTodos(Array.from(result.data));
                    clearTimeout(result.timeoutId);
                    setRefreshTodos(false);
                    setLoadingTodos(false);
                  });
                }
              }, [refreshTodos]);

              useEffect(() => {
                const timeOutId = setTimeout(() => {
                  if (errorMessage) {
                    setErrorMessage("");
                    if (deletingToDo) {
                      setDeletingToDo("");
                    }
                  }
                }, 3000);

                return () => {
                  clearTimeout(timeOutId);
                };
              }, [errorMessage]);

              return (
                <div>
                  {loadingTodos && <p>Loading...</p>}

                  {errorMessage && (
                    <div>
                      <p style={{ color: "red" }}>{errorMessage}</p>
                      <button
                        onClick={() => {
                          setErrorMessage("");
                          if (deletingToDo) {
                            setDeletingToDo("");
                          }
                        }}
                      >
                        Back to list
                      </button>
                    </div>
                  )}

                  {!creatingNew && todos.length > 0 && !loadingTodos && !errorMessage && (
                    <>
                      <button
                        autoFocus
                        onClick={() => {
                          setCreatingNew(true);
                        }}
                      >
                        Create New
                      </button>
                      <ul>
                        {todos.map((todo) => (
                          <li key={todo}>
                            <p>
                              {todo}{" "}
                              {deletingToDo === todo ? (
                                "Deleting..."
                              ) : (
                                <button
                                  onClick={() => {
                                    setDeletingToDo(todo);
                                    dbApi
                                      .deleteTodo(todo)
                                      .then((result) => {
                                        clearTimeout(result.timeoutId);
                                        setRefreshTodos(true);
                                        setDeletingToDo("");
                                      })
                                      .catch((reason) => {
                                        setErrorMessage(reason.message);
                                      });
                                  }}
                                >
                                  delete
                                </button>
                              )}
                            </p>
                          </li>
                        ))}
                      </ul>
                    </>
                  )}

                  {(creatingNew || todos.length === 0) && !loadingTodos && (
                    <>
                      <p>Create a new todo:</p>
                      <form
                        onSubmit={(e) => {
                          e.preventDefault();
                          setSavingTodo(true);
                          dbApi.addTodo(todo).then((result) => {
                            setTodo("");
                            setCreatingNew(false);
                            clearTimeout(result.timeoutId);
                            setRefreshTodos(true);
                            setSavingTodo(false);
                          });
                        }}
                      >
                        {savingTodo ? (
                          <p>Saving...</p>
                        ) : (
                          <input
                            autoFocus
                            onChange={(e) => {
                              setTodo(e.target.value);
                            }}
                          ></input>
                        )}
                      </form>
                    </>
                  )}
                </div>
              );
            }

            export default App;
          
        
Open in Stackblitz / Github repo

Implicit states:

            
              {!creatingNew && todos.length > 0 && !loadingTodos && !errorMessage && (
              
            

In a larger application where variables/props/data come from dispirate parts of your application, this can be a nightmare to untangle and make sense of.

App logic spread out and split up throughout the component:

In event handlers:

              
                onClick={() => {
                  setDeletingToDo(todo);
                  dbApi
                  .deleteTodo(todo)
                  .then((result) => {
                    clearTimeout(result.timeoutId);
                    setRefreshTodos(true);
                    setDeletingToDo("");
                  })
                  .catch((reason) => {
                    setErrorMessage(reason.message);
                  });
                }}
              
            

And useEffects (which are bug prone):

            
              useEffect(() => {
                const timeOutId = setTimeout(() => {
                  if (errorMessage) {
                    setErrorMessage("");
                    if (deletingToDo) {
                      setDeletingToDo("");
                    }
                  }
                }, 3000);
                
                return () => {
                  clearTimeout(timeOutId);
                };
              }, [errorMessage]);
            
          

Our UI is tightly coupled with our App logic.

This hurts portability, making it hard to:

  • Move logic between apps, eg. same checkout flow in a vue and a react project.
  • Keep Web and Native projects in sync (React/React Native)
  • Switch UI libraries, or get rid of them alltogether :)

React + Xstate

          
            import { useMachine } from "@xstate/react";
            import { todosMachine } from "./todoAppMachine";

            function App() {
              const [state, send] = useMachine(todosMachine);

              return (
                <>
                  <div>
                    {(state.matches("Loading Todos") ||
                      state.matches("Retry loading todos")) && <p>Loading...</p>}

                    {state.matches("Show Todos") && (
                      <>
                        <button
                          autoFocus
                          onClick={() => {
                            send({ type: "Create new todo" });
                          }}
                        >
                          Create New
                        </button>
                        <ul>
                          {state.context.todos.map((todo) => (
                            <li key={todo}>
                              <p>
                                {todo}{" "}
                                {state.context.todoToDelete === todo ? (
                                  "Deleting..."
                                ) : (
                                  <button
                                    onClick={() =>
                                      send({ type: "Delete todo", value: todo })
                                    }
                                  >
                                    delete
                                  </button>
                                )}
                              </p>
                            </li>
                          ))}
                        </ul>
                      </>
                    )}

                    {(state.matches("Deleting todo errored") ||
                      state.matches("Creating new todo.Saving todo errored")) && (
                      <div>
                        <p style={{ color: "red" }}>{state.context.errorMessage}</p>
                        <button
                          onClick={() => {
                            send({ type: "Back" });
                          }}
                        >
                          Back
                        </button>
                      </div>
                    )}

                    {state.matches("Loading todos errored") && (
                      <div>
                        <p style={{ color: "red" }}>{state.context.errorMessage}</p>
                        <p>Experiencing som issues, please come back later.</p>
                      </div>
                    )}

                    {state.matches("Creating new todo") &&
                      !state.matches("Creating new todo.Saving todo errored") && (
                        <>
                          <p>Create a new todo:</p>

                          {state.matches("Creating new todo.Saving todo") && (
                            <p>Saving...</p>
                          )}

                          {state.matches("Creating new todo.Showing form input") && (
                            <form
                              onSubmit={(e) => {
                                e.preventDefault();
                                send({ type: "Submit" });
                              }}
                            >
                              <input
                                autoFocus
                                onChange={(e) => {
                                  send({
                                    type: "Form input changed",
                                    value: e.target.value,
                                  });
                                }}
                              ></input>
                            </form>
                          )}
                        </>
                      )}
                  </div>
                </>
              );
            }

            export default App;
          
        
Open in Stackblitz / Github repo

Explicit states

            
              {state.matches("Show Todos") && (
              
            

Yeah... No guessing what state our application is in.

No App logic in our components, and no useEffects!

              
                onClick={() =>
                  send({ type: "Delete todo", value: todo })
                }
              
            

We just send an event and any relevant data to a centralized place that holds and exceutes our logic.

Anyone thinking of Redux and reducers? :) Unfortunately, Redux only tells half of the story. With Statecharts we can paint the full picture:

App logic from React + Xstate example:

A Statecharts diagram that represents the App logic for the Todos App.
View in online editor

Yes, that is the code ...kind of:

Statecharts is a visual formalism for complex systems. Meaning it is a formalized notation that is visual and can accurately describe application logic. Xstate is a library that implements Statecharts in Javascript/Typescript. Stately Studio is a tool that let's you author Xstate code in a visual editor. It integrates with VSCode, and features bidirectional editing of code/diagram:

The actual Xstate code from our React + Xstate example:

        
          import { createMachine, assign } from "xstate";
          import { dbApi } from "./api";
          
          export const todosMachine =
          /** @xstate-layout N4IgpgJg5mDOIC5QBUD2FWwAQFkCGAxgBYCWAdmAHQAyqeE5UWaGsAxBhZeQG6oDWVFplyFSXWvUbN0mBL1QE8AFxKoyAbQAMAXW07EoAA6YSq9YZAAPRACYAnADZKtrQFYAjB4DMjgBwA7A4etgA0IACeiL62lFq23t4BAfaeAQAsIQC+WeHC2PjE5FSSDGRM+RzqVAqClPmiRRJ0ZRWysPJkfErmmroaHgZIICawZmpkljYIjgmUGfYJjgG+Wn5e4VEISR4uDumOHvYBHm4pfuk5ee2N4iUt0pVgAE7PqM+URgA2KgBm7wBbeo3Qp3GgPcoyVidboqCb6fSWUbjCzDaZuRaUM4eLQ4+zrdwBTaIDyOLSUQLpQmZRzpQLZXIgBqg4qUADKRFQAHcoZg2ABhZ5gFRgLAUHnKWSI4bI3pTElU8lnPyePwJXEePzeYkzTUuOkeKkY+LedKXRnMsSsjnc3mwSgAETAXzAqkhkowVS4tSEIKtXBtPPyjudrukHtQMMUcPUCN0SNMcrRdi05Mc3lsbjcmS0mSOfh12bcFLc3icfnsxzNZquTL9TSogbtIZdbqYEbYLzeH2+f0BwNYt2tnKD7RbYfdsijPXh-XjMsTE3lCBW3koqW8pfSmdxdMcOuSAUo3lOTniAVTiQ8tctDfZI+bJAgLrYTtbooj0uMi9RoGm62LDFHCcVw1UpbVIkQWZ0jiM0tGA-wkkOa8LXrMFBWFNsxTACVZHvblpH+Z4ASwcgjAAV2UNgADFAVIsgKOULBiDwcpIC-EYf0mZMEGOew4lcEJ7DWZC3B1Ww1XXPxpPSVIKxPLUbzQ1kMLhSFxSwCN8K5Qi6LIyi2DZciACMATMDjZSXHjDVNdcqT3Tw3HcUkdUSZxjm8Px3FNLyzkcJTBxZLhVKwjStLZPAeHDWQvRqLoBF9QL-SoELpDCvCIqiycMGnGM+j0edvzGJM-xJU0j3WFVHEcJzbG3CCtgAWjcZwjkybwtHsDrhL8Gr-NQpK71S9ScM0jLIuiz0u3eT4fmUIigVvdChTUph0owdkJuyyMFBnWM5yGIqUW40qEB8bdKC8dI3FsWxkgkolILOrN5mAisauk3yApEIKqDfCd21kLBpqFCA2AAIUIfgLK45dM2LexrsSM0EKOG6dWWNc7t8zNbFOarvoKZLxywiNgded5IDYKxYGUEVKDwX5lBeAAKDrUwASjYJbWX+0mgZB9jCs44qrNOnyKS0AJkKcMsggLJ6gn4rNhNOI4TmSfrrkG5bMLS0bwq2wGMHJ7sqZpunmYZpnWfZrQuZ54KVtCg3xqy43UFNymIBh0Xf2sEkfH4rQOruuraQ8IIdTV49cz4iT0ySO7CaHJ29ZG3CNsyybPcFsHIYIaHhcs-3phxDE4gvLNZk83r7APfjM3SE9hNus0HACHJGTIdA4EsR2wATP2ToD57YnTeGczzfEdUamzKGWfE1mEzxhNzFPfvBKRIXyIfjuXDNdmlk5kd6lJMnSHUDkrqkcTNHxbsUgafuJptd4XYflxqtd4NmaSNYSJqQs8E4jxBanjYS254jmm1i-O8b8xx8xznvEqo8vB1XXJqNU6QUhrFsNVQsXVKB0jVGWJyUsbqd2fkTeBD5gxPhdCgsWo8Ly7F-hJekQQFIY2viqVMKx7LrAcBvYmpQc7YDzkw0uUE9QnGWA4aqeJDjiWOC4C4nkki+E3O1ERQ1nb60zqgKRI9phdTYYJBwIkvBiSeo1PwLgTybgrKqTyVJbC6N1qtbChjtK6WIvRRixjlxeFTAvZUQQgjZmUU9PGa5nG5juldTw10PEqX0RnMaWcjaZKMR-fe1kyRHl8JHLqElCROHEqSWCuZTgawvCsLWdYda81DPzE2ki8moOmPg5wiEem3RavEBWWxzhYnxE5C4ARq6zFSWnLx61UCbXdjkr2oMgnWQSEeDIFZm4nhsjghuLhswt3iHVbcxwu5ZCAA */
          createMachine(
            {
              context: {
                todos: [] as string[],
                errorMessage: undefined as string | undefined,
                createNewTodoFormInput: "",
                todoToDelete: "",
              },
              tsTypes: {} as import("./todoAppMachine.typegen").Typegen0,
              schema: {
                events: {} as
                | { type: "Create new todo" }
                | { type: "Form input changed"; value: string }
                | { type: "Submit" }
                | { type: "Delete todo"; value: string }
                | { type: "Back" },
                services: {} as {
                  loadTodos: {
                    data: string[];
                  };
                  saveTodo: {
                    data: void;
                  };
                  deleteTodo: {
                    data: void;
                  };
                },
                },
                initial: "Loading Todos",
                states: {
                  "Loading Todos": {
                    invoke: {
                      src: "loadTodos",
                      onDone: [
                      {
                        target: "Show Todos",
                        cond: "Has todos",
                        actions: "assignTodosToContext",
                      },
                      {
                        target: "Creating new todo",
                      },
                      ],
                      onError: [
                      {
                        target: "Loading todos errored",
                        actions: "assignErrorMessageToContext",
                      },
                      ],
                    },
                  },

                  "Show Todos": {
                    on: {
                      "Create new todo": {
                        target: "Creating new todo",
                      },
                    },
                    
                    states: {
                      "Deleting todo": {
                        invoke: {
                          src: "deleteTodo",
                          onDone: "#Todos Machine.Loading Todos",
                          onError: {
                            target: "#Todos Machine.Deleting todo errored",
                            actions: "assignErrorMessageToContext",
                          },
                        },
                      },
                      
                      idle: {
                        on: {
                          "Delete todo": {
                            target: "Deleting todo",
                            actions: "assignTodoToDeleteToContext",
                          },
                        },
                      },
                    },

                    initial: "idle",
                  },
                  
                  "Loading todos errored": {},
                  
                  "Creating new todo": {
                    initial: "Showing form input",
                    states: {
                      "Showing form input": {
                        on: {
                          "Form input changed": {
                            actions: "assignFormInputToContext",
                          },
                          Submit: {
                            target: "Saving todo",
                          },
                        },
                      },
                      
                      "Saving todo": {
                        invoke: {
                          src: "saveTodo",
                          onDone: [
                          {
                            target: "#Todos Machine.Loading Todos",
                          },
                          ],
                          onError: {
                            target: "Saving todo errored",
                            actions: "assignErrorMessageToContext",
                          },
                        },
                      },
                      
                      "Saving todo errored": {
                        after: {
                          "3000": "Showing form input",
                        },
                        
                        on: {
                          Back: "Showing form input",
                        },
                      },
                    },
                  },

                  "Deleting todo errored": {
                    after: {
                      "3000": {
                        target: "#Todos Machine.Show Todos",
                        actions: [],
                        internal: false,
                      },
                    },
                    on: {
                      Back: {
                        target: "Show Todos",
                      },
                    },
                  },
                },
                id: "Todos Machine",
              },
              {
                guards: {
                  "Has todos": (context, event) => event.data.length > 0,
                },
                services: {
                  loadTodos: async () => {
                    const result = await dbApi.getTodos();

                    return Array.from(result.data);
                  },
                  saveTodo: async (context, event) => {
                    const result = await dbApi.addTodo(context.createNewTodoFormInput);
                    // todos.add(context.createNewTodoFormInput);
                  },
                  deleteTodo: async (context, event) => {
                    const result = await dbApi.deleteTodo(context.todoToDelete);
                  },
                },
                actions: {
                  assignTodosToContext: assign((context, event) => ({
                    todos: event.data,
                  })),
                  assignErrorMessageToContext: assign((context, event) => ({
                    errorMessage: (event.data as Error).message,
                  })),
                  assignFormInputToContext: assign((context, event) => ({
                    createNewTodoFormInput: event.value,
                  })),
                  assignTodoToDeleteToContext: assign((context, event) => ({
                    todoToDelete: event.value,
                  })),
                },
              }
            );
            
        
      

Not the prettiest thing in the world

Allright, so that big JS object might not be the prettiest thing in the world. However it centralizes app logic, and completely decouples it from the UI components! It might look clunky, but is simpler than what we usually end up with on our own. With the studio it is quite easy to reason about and edit as well.

Simulate behaviour

Another useful feature is to simulate how our app will respond to different events, even if we hadn't written any UI for it yet:

Spot the bug?

We can identify undesired behaviour in our app, if we look closely at the simulation video:

The state 'Loading todos errored' highlighted, with a single arrow pointing towards it.

There are no arrows (transitions) out of this state, so if our loadTodos service errors, our app can't recover. Let's fix that!

First, the app logic:

The state 'Loading todos errored', with a transition to the state 'Loading todos'. Red text saying: 'The fix', pointing to the transition.

And then for the UI:

        
          {state.matches("Loading todos errored") && (
            <div>
              <p style={{ color: "red" }}>{state.context.errorMessage}</p>
              <p>Hold on tight, will try again soon.</p>
            </div>
          )}
        
      

This will make the app retry loading the todos, and the UI informs the user what is going on. But it will loop forever, or until our service is up again. Let's try to improve this a bit.

First model/diagram:

A better fix that introduces an intermediate state, delays, guards and some new actions.

And then code changes (not generated by the studio/diagram) in todosMachine.ts and App.tsx:

        
          // todosMachine.ts:
          ...
          context: {
            ...
            retries: 0, // we keep track of data in this context object in Xstate
          },
          ...
          
          ...
          guards: {
            ...
            "not max retries": (context, event) => context.retries < 5,
          }
          ...
          
          ...
          actions: {
            "increase retries count": assign((context, event) => ({
              retries: (context.retries += 1),
            })),
            "reset retries count": assign((context, event) => ({
              retries: 0,
            })),
            ...
          }
          ...

          // App.tsx:
          ...
          {(state.matches("Loading Todos") ||
          state.matches("Retry loading todos")) && <p>Loading...</p>} // Keep loading message if retrying
          ...
          
          ...
          {state.matches("Loading todos errored") && (
            ...
            <p>Experiencing som issues, please come back later.</p> 
            ...
            )} // Change text
            ...
          
        

On the way we got excellent Typescript support from Xstate:

Showing how VSCode helps with autocompleting states in App.tsx, and actions in todosMachine.ts

Now we:

We could improve this further, but let's stop here for the sake of this article.

In conclusion

I hope this small exercise has demonstrated how Xstate & Statecharts can:

I would also like to point towards other benefits:

Reducing bugs

One key feature of State Machines / Statecharts is that our application can be in exactly one state at any given time. And that one state determines which "actions / signals / events" our application responds to and how. This reduces bugs by:

Statecharts is a great collaborative tool ❤️

Designers, developers and other stakeholders can all contribute. During R&D we can produce a unique artifact that draws a clear path towards the end goal of producing software that solves the exact problems that we aim to solve. We all speak the common language of boxes and arrows! This artifact can be version controlled, and live inside our codebase, driving the behaviour of our application. In fact all stakeholders can contribute to this throughout the lifefime of the software.

Challenges

Nothing is perfect, and there are some challenges which are good to be aware of:

Unfamiliar way of coding

We are used to spending a relatively short amount of time planning before we code. We deal with complexity later on, as the codebase grows. Getting started though, is easy and lightweight.

Using Statecharts, it's flipped. We start out with a bunch of planning. What is this weird cognitive load, before writing a single line of code? That stuff is supposed to come later. How could this be any fun!? We are coders are we not?

I believe this is a normal initial reaction to the unfamiliar way of working with Statecharts. I also think it passes when you experience the benefits of dealing with complexity upfront, simplifying code and reduce the amount of custom abstractions we have to build ourselves :)

Learning curve

The learning curve for Statecharts and Xstate has been pretty steep, but that was before 2022. The Stately Team has done a tremendous job in flattening it. With the introduction of the Stately Studio, VSCode extension, excellent Typescript support, YouTube channel and last but not least, a super helpful tutorial section right in the Studio:

Screenshot of the Visual Editor and how clicking the blue '? icon' opens up a built-in tutorial.

Large Machine Definitions / Diagrams

Xstate supports the Actor model, and a machine is an actor that can send and recieve messages. This makes it possible to have smaller machines / diagrams that communicate with each other through messages. The Actor model is powerful, and should make sense from a modeling consideration. Not a mechanism for keeping diagrams readable.
Disclaimer: I have only written a small handful of Xstate machines, I might not know what I am talking about.

So, that means diagrams can easily become quite large:

Image of a large Statecharts diagram. There are a lot of arrows and boxes, impossible to read the text because it is zoomed to 27% of original size to fit the whole diagram.
Wasn't the whole point to simplify things? LOL!

True, that looks complex. But it is a simplification of what we usually do, let me explain with 2 arguments:

  1. Statecharts reveal complexity.

    They paint a map of the inherent complexity of the problem at hand. How often can we successfully, and accurately, describe how an application functions, including edge cases, by looking at code?

  2. Better than large amounts of text.

    Closely related to the previous point. Reading large amounts of code, top to bottom, file by file is tedious, hard and not easy to get right. Zooming, scrolling and following arrows is still an improvement for understanding application logic.

And last but not least regarding large machines: The Stately Team is making improvements each day, and they are exploring ideas, like collapsable states / areas, from their open feedback ➝ roadmap thingy:

2 images of the same Statechart side by side, showing some states collapsed and uncollapsed.

I'm confident they will cook up something great for this!

Next moves

I hope this article has peaked your interest for Statecharts! And that you would like to incorporate them into your next software endeavour! Xstate is framework agnostic, and runs anywhere Javascript runs. Backend / Frontend / Embedded Systems, all can benefit!

Statecharts also enables new possibilities like:

I would encourage you to watch this excellent talk by David Khourshid, creator of Xstate and founder of Stately.

Useful Resources

Some definitions:

I haven't defined some of the terms I use in this article, and while not getting too extensive here is an overview:

(Finite) State Machines (FSM)

A model of computation, developed in the 1940/50s I believe. Consists of 5 things:

  1. An initial state
  2. A finite number of states
  3. Transitions between those states
  4. Actions
  5. A final state

Statecharts

Invented by David Harel in early 1980s. Statecharts are essentially finite statemachines, but with a notation that makes them manageable and scalable.

Xstate

An implementation of Statecharts & the Actor model in JavaScript / Typescript

Stately

The company behind Xstate, it's visual editor and online tools.

Feedback

Any feedback is appreciated and can be given on Twitter or Github Discussions.