For a while I’ve wanted to learn the XState library, which is presented as a solution to gnarly UI state issues where “what should we show and how should it act?” becomes hard to determine.

Since the best way to learn is to actually build something, I’m thinking I can try to port an old project of mine over to XState, and see how it turns out. Continue reading if you want to follow along!

Why state machines?

I’ve been building a bunch of app or app-like user interfaces with React and other frameworks, and you quickly run into situations where a screen or component’s UI state is represented by a series of variables.

For instance, if a component loads data from a server, you can end up with variables like:

  • data (undefined, empty Array, or Array with data)
  • error (undefined or an error object or message)
  • loading (boolean)

You end up writing conditions like this:

const hasContent =
  !error &&
  !loading &&
  Array.isArray(data) &&
  data.length > 0;

Add a couple more UX or business rules to this UI component, and you’re in trouble. Your code will be littered with complex conditions, and it’s easy to get one of those wrong and end up with subtle bugs where different parts of the UI disagree about what state the application or component is in. So, what’s a poor UI developer to do?

Enter finite-state machines.

As a literature major with no formal computer science training, I’m not quite sure what I’m talking about, but apparently a “finite-state machine” is a programming concept where:

  • the program has a list of possible states;
  • it can be in only one state at a time;
  • and it defines how each state can or cannot transition to other states.

This can be used to describe physical devices like elevators, traffic lights, vending machines and more, and often those devices do run programs that implement a finite-state machine.

Last year I built a tiny click precision game, with zero experience in game development. I used Svelte, and designed the game’s state somewhat naively. Often thinking “I should use an established formalism here” but being pressed for time, I improvised some ad hoc code.

Shortly after that, I heard of XState, a JavaScript library that helps with implementing state machines. And I wondered how would things have worked out if I had used XState. Let’s find out!

How the game currently works

Since this is a refactoring operation, we should review key parts of the current code for click-precision-game. How does it handle the main gameplay state?

Let’s look at the main gameplay in the Playground.svelte component. There’s a lot of logic there. The game follows a linear timeline where each instance of the game is divided in two parts:

  1. A countdown, divided in a few phases (showing “click the green square”, “3”, “2”, “1”).
  2. Then a series of 20 “turns”, each turn divided in 2 phases: the “turn” itself (time when the game’s target is visible and must be clicked), and a “cooldown” (short pause before the next turn).

That information is encoded as a collection of objects:

export const PLAY_PHASES = {
  START: {
    next: "COUNTDOWN_3",
    durationRatio: 1,
    minDuration: 1000,
    countdown: 4,
    showTarget: false,
  },
  COUNTDOWN_3: {
    next: "COUNTDOWN_2",
    durationRatio: 0.5,
    minDuration: 400,
    countdown: 3,
    showTarget: false,
  },
  COUNTDOWN_2: {
    next: "COUNTDOWN_1",
    /* … */
  },
  COUNTDOWN_1: {
    next: "TURN",
    /* … */
  },
  TURN: {
    next: "COOLDOWN",
    /* … */
  },
  COOLDOWN: {
    next: "TURN",
    /* … */
  }
};

When the Playground component is mounted, we’re triggering the first phase:

onMount(() => {
  startPhase("START");
});

And the startPhase function does the heavy lifting. Here it is, partly abridged and annotated:

function startPhase(key) {
  // If we’re just starting a turn
  if (key === "TURN") {
    // increment the turn count
    turns += 1;
    // reset the success/failure state to a neutral value
    turnState = TURN_STATE.INITIAL;
    // place the target at a random position
    targetPosition = getTargetPosition();
  }

  // If ending a turn, we check the component state
  // to see if we have registered a successful click
  // on the target
  if (key === "COOLDOWN") {
    if (turnState === TURN_STATE.SUCCESS) {
      successCount += 1;
    }
  }

  // Get config for this phase
  const phase = PLAY_PHASES[key];
  // update UI
  countdown = phase.countdown;
  showTarget = phase.showTarget;
  // schedule next phase
  timeout = setTimeout(
    () => startPhase(phase.next),
    Math.max(phase.minDuration, phase.durationRatio * $gameSpeed)
  );
}

Note that the countdown and showTarget variables are reactive, and tracked by Svelte. When we update their value, Svelte does its reactivity magic and updates the view. If we were in React land, we could probably use useState and update these state variables with:

const [countdown, setCountdown] = useState();
const [showTarget, setShowTarget] = useState();

const startPhase = (key) => {
  /* … */
  setCountdown(phase.countdown);
  setShowTarget(phase.showTarget);
  /* … */
}

Anyway, that’s how it works right now. Doing a bit of reading on statecharts and this introduction to state machines by David Khourshid on CSS-Tricks, it looks like I was close enough to a hand-rolled finite state machine. Yay me!

Getting comfortable with XState

Originally I wanted to open the XState docs in one tab, my current code on another screen, and just start converting stuff and figuring things out along the way.

Turns out that XState is a bit more complex than I thought. It’s not very hard, but there are a few concepts to learn: machines, services, actions, actors maybe? Is that more than I bargained for?

So, change of plan: before tacking the gameplay state, I want to make sure I’m comfortable with basic usage of XState and how to integrate it in Svelte.

Let’s start with a simpler bit of state then; I picked the main navigation state.

The game has 3 different screens:

  • "setup" (configures a game’s parameters before starting)
  • "playing" (plays the game’s main loop)
  • "results" (shows your points)

We start at the Setup screen:

A start screen with form fields for choosing a target size, playground size and game speed, and a “Start” button.

The information for “which screen should we show?” is stored as a string in a Svelte store (a reactive variable that can be shared between components). It’s defined like this:

import { writable } from "svelte/store";

export const screen = writable("setup");

And in our root component we have a kind of switch statement where we pick which component to render:

<script>
  import { screen } from "../store.js";
  import Setup from "./Setup.svelte";
  /* … */
</script>

{#if $screen === "setup"}
  <Setup />
{:else if $screen === "playing"}
  <Playground />
{:else if $screen === "results"}
  <Results />
{/if}

To navigate between screens, we change the store’s value:

function startPlaying() {
  $screen = "playing";
}

Note: we’re using the dollar syntax, $screen, to access the value of the screen store in a reactive way (read more in the Svelte docs).

Converting this to XState is probably overkill, but using XState we can enforce a few rules:

  1. Restrict the state to known values (e.g. make it impossible to set the current screen to "whoops" if that’s not a known state).
  2. Specify relationships between states, e.g. only the "playing" state can go to the "results" state.

Let’s create a basic XState machine to keep track of the current screen:

// src/state/screen.js
import { Machine } from "xstate";

const screenMachine = Machine({
  id: "screen",
  initial: "setup",
  states: {
    setup: {},
    playing: {},
    results: {}
  }
});

Right now our 3 states have no rules or features attached, but we’ll flesh them out later.

Now if I’m understanding correctly, a XState “machine” is a static set of rules, but it is itself stateless: it doesn’t have a current state or a state history. To hold a “current value”, we need a XState “service”:

// src/state/screen.js
import { interpret, Machine } from "xstate";

const screenMachine = Machine({
  id: "screen",
  initial: "setup",
  states: {
    setup: {},
    playing: {},
    results: {}
  }
});

const screenService = interpret(screenMachine);
export default screenService;

This feels a bit like a class-vs-instance thing. We have a kind of blueprint (a class, or a machine in this case) which is used to create a structure that holds data or state (instance/service). Not sure if that makes sense, this is just how it looks to me. 😅

Let’s update our top component to use the screenService instead of the screen Svelte store:

<script>
  import screenService from "../state/screen.js";
  import Setup from "./Setup.svelte";
  /* … */
</script>

{#if screenService.state.matches("setup")}
  <Setup />
{:else if screenService.state.matches("playing")}
  <Playground />
{:else if screenService.state.matches("results")}
  <Results />
{/if}

This looks good to me, but alas! it fails. We get a blank screen. What’s going on in the Console?

Warning: Attempted to read state from uninitialized service 'screen'. Make sure the service is started first.

Indeed, when we run console.log(screenService.state), that’s undefined! Looks like we have to start the service to make it go to its initial state ("setup"):

const screenService = interpret(screenMachine);
screenService.start();

That works! I’m seeing the Setup screen, and if I change the initial value in stateMachine and reload the page I can see the Playground or Results components — though Results are broken, because the result data is missing.

So we have a navigation state. Now we need some navigation actions, i.e. a way to go from one state to the other.

XState handles that with events, which are defined on the machine. It looks like, by convention, state names are lowercase and event names are uppercase, so we’ll follow that.

const screenMachine = Machine({
  id: "screen",
  initial: "setup",
  states: {
    setup: {
      on: {
        START_PLAYING: "playing"
      }
    },
    playing: {
      on: {
        SHOW_RESULTS: "results",
        SHOW_SETUP: "setup"
      }
    },
    results: {
      on: {
        SHOW_SETUP: "setup"
      }
    }
  }
});

With that setup:

  1. to start playing, we must be on the "setup" state and send a "START_PLAYING" event;
  2. the "playing" state can go to the "results" state or to the "setup" state (e.g. if the user wants to interrupt a game);
  3. the "results" state can only go back to the "setup" state, to start a new game.

And we could add events to handle more features. For instance, if we want to add a “Try Again” button on the results screen that starts a new game with the same parameters, we can add: PLAY_AGAIN: "playing".

We send events with the service’s send method. Let’s start with the Setup page. It listens to the form’s "submit" event and navigates, so we’ll update that:

<!-- src/components/Setup.svelte -->
<script>
  import screenService from "../state/screen.js";

  function onSubmit(event) {
    event.preventDefault();
    screenService.send("START_PLAYING");
  }
</script>

<form on:submit={onSubmit}>
  <!-- … -->
</form>

And… it doesn’t work.

Nothing in the Console. Hmm, how else can we debug what’s happening? We have two options:

Looking closer, once we call send("START_PLAYING"), here is what happens:

  1. The event is dispatched correctly on the screenService.
  2. The screenService’s state changes to "playing".
  3. But nothing in Game.svelte reacts to this change.

We need to make Svelte aware of changes in the screenService!

Thankfully, XState’s docs have a recipe for using XState with Svelte, which shows two solutions. My favorite is to treat the XState service as a Svelte store, which is possible because a XState service has a subscribe method that fulfills Svelte’s “store contract”.

In short, that means we can use $screenService as a reactive version of screenService.state. Our code becomes:

<script>
  import screenService from "../state/screen.js";
  /* … */
</script>

{#if $screenService.matches("setup")}
  <Setup />
{:else if $screenService.matches("playing")}
  <Playground />
{:else if $screenService.matches("results")}
  <Results />
{/if}

And with that, navigating between screens finally works!

Tackling the main game loop

Okay, that wasn’t super simple and there was a bit of a learning curve, but we finally know how to make a basic state machine with XState. Should we create one for the PLAY_PHASES structure we described earlier? As a reminder, it looked like this:

export const PLAY_PHASES = {
  START: {
    next: "COUNTDOWN_3"
  },
  COUNTDOWN_3: {
    next: "COUNTDOWN_2"
  },
  COUNTDOWN_2: {
    next: "COUNTDOWN_1"
  },
  COUNTDOWN_1: {
    next: "TURN"
  },
  TURN: {
    next: "COOLDOWN"
  },
  COOLDOWN: {
    next: "TURN"
  }
};

The first-level keys are our states, and we have just one event, "next", telling us what the next state should be.

As a XState machine, this could look like:

const playMachine = Machine({
  id: "play",
  initial: "start",
  states: {
    start: {
      on: { NEXT: "countdown_3" }
    },
    countdown_3: {
      on: { NEXT: "countdown_2" }
    },
    countdown_2: {
      on: { NEXT: "countdown_1" }
    },
    countdown_1: {
      on: { NEXT: "turn" }
    },
    turn: {
      on: { NEXT: "cooldown" }
    },
    cooldown: {
      on: { NEXT: "turn" }
    }
  }
});

While working on the XState refactor for this article, I realized that the 4 first states (from "start" to "countdown_1) represent a single countdown animation with 4 “stages”. To simplify the state logic a bit, I decided to turn those states into a single “countdown” state, and moved the animation logic to a CSS animation in the Countdown component. This simplifies our machine:

const playMachine = Machine({
  id: "play",
  initial: "countdown",
  states: {
    countdown: {
      on: { NEXT: "turn" }
    },
    turn: {
      on: { NEXT: "cooldown" }
    },
    cooldown: {
      on: { NEXT: "turn" }
    }
  }
});

Another problem to solve: how do we start this playMachine at the right time in our app’s life?

One solution would be to create and start a new playService every time that the Playground component is mounted.

Another option is to merge our screenMachine and our playMachine in one hierarchical state machine. The states from our playMachine can be children of the playing state.

(You don’t need to have a single machine for your whole app’s state. It’s perfectly okay to have several machines for several features or screens. But since the game loop only exists in the "playing" screen, a single hierarchical machine makes sense here.)

Our merged machine will handle most of the game’s state, so let’s call it gameMachine:

// src/state/game.js
import { interpret, Machine } from "xstate";

const gameMachine = Machine({
  id: "game",
  initial: "setup",
  states: {
    setup: {
      on: {
        START_PLAYING: "playing",
      }
    },
    playing: {
      on: {
        SHOW_RESULTS: "results",
        SHOW_SETUP: "setup"
      },
      initial: "countdown",
      states: {
        countdown: {
          on: { NEXT: "turn" }
        },
        turn: {
          on: { NEXT: "cooldown" }
        },
        cooldown: {
          on: { NEXT: "turn" }
        }
      }
    },
    results: {
      on: {
        SHOW_SETUP: "setup"
      }
    }
  }
});

const gameService = interpret(gameMachine);
gameService.start();

export default gameService;

This machine can be visualized using XState’s interactive visualizer:

If you try clicking on the event names in the visualization, you’ll see that:

  1. We start at the setup state.
  2. We can go to the playing state, which starts at its playing.countdown state.
  3. In the playing state, we can go to playing.turn, then to playing.cooldown, then to playing.turn again, and we stay there in a loop.
  4. We can go from the playing state forward to the results state, or back to setup.

Pretty good. Now, in the Playground component we need to react to changes in the game state to:

  • show or hide the countdown;
  • show the square target with a new position, and increment the turn count;
  • hide the square target.

Most of that logic happens in the startPhase function we described earlier. Our previous logic relied on calling startPhase recursively, with a delay. Schematically, it looked like:

import { onMount } from "svelte";
import { PLAY_PHASES } from "./constants.js";

onMount(() => {
  startPhase("countdown");
});

function startPhase(key) {
  // 1. Perform side effects (update the UI):
  // …
  // 2. Set a timer for the next phase:
  const nextKey = PLAY_PHASES[key].next;
  const duration = PLAY_PHASES[key].duration;
  setTimeout(() => {
    startPhase(nextKey);
  }, duration);
}

With our state machine, we’re going to split this in two:

  • We’re going to call startPhase every time the gameService’s state changes. For that, we need to subscribe to updates.
  • At the end of a game phase (in our setTimeout function), we’re going to send a "NEXT" event instead of calling startPhase directly.
import { onMount, onDestroy } from "svelte";
import gameService from "../state/game.js";
import { gamePhaseDurations } from "../state/setup.js";

// Listen to state transitions
onMount(() => {
  gameService.onTransition(startPhase);
});
onDestroy(() => {
  gameService.off(startPhase);
});

function startPhase(state) {
  // state.value looks like: {playing: "countdown"}
  const key = state.value.playing;
  // 1. Perform side effects (update the UI):
  // …
  // 2. Set a timer for the next phase:
  const duration = $gamePhaseDurations[key];
  setTimeout(() => {
    gameService.send("NEXT");
  }, duration);
}

And we’re done!

The actual logic is a bit more complex — it handles things like counting turns and points, sending a send("SHOW_RESULTS") event after 20 turns, etc. — but this is a good representation of how the state machine powers the game loop.

My impressions of XState

I found XState to be more complex than I had anticipated. Granted I knew next to nothing about state machines and statecharts. But the learning curve was a bit steep, I think, for two reasons:

  1. There’s a bit of jargon to wrap your head around.
  2. XState has a lot of features, and it’s easy to get lost asking yourself if one feature is essential for your use case or not, or that other feature, or maybe this one…

At first I tried diving in for a couple hours, writing some code, expecting to know my way around the concepts and library in that time-frame. That didn’t work at all.

So I took a step back, actually read some of the docs, watched a couple videos and read some articles, before diving back in with a smaller scope: porting the navigation state.

After that, porting the main game loop to XState was still a lot of work, but mostly because my own UI logic had a lot of parts to account for, and changing some of its state management meant I had a lot of loose ends to reconnect.

An operator plugging cables in an very big switchboard
Me trying to make Playground.svelte work again.

While I initially wanted to spend 1–2 days learning XState, I ended up spending close to a week learning and refactoring, and then 2–3 days writing this post (I’m a slow writer).

There were a few more things I wanted to try, but had to drop for lack of time:

  1. Statecharts can have “extended state”, called “Context” in XState. It looks like it would make sense to store the points and turn count in the gameService’s context.

  2. With the turn count stored in the gameService, the branching logic for going to the next turn or ending the game could be part of the state machine as well (instead of the Playground component’s state and the startPhase function). Probably using Guarded Transitions.

  3. I tried putting the playing phase duration config on the state machine, using state meta data, but it was awkward and created other issues for that use case.

Would I use XState on a new project? Definitely. Being able to reason about an app, screen or feature’s state by reading a statechart definition is invaluable, and the visualizations are a nice touch. And while I haven’t used most of the advanced features, reading up on them makes me confident that I could handle complex use cases.

A word of caution though: when bringing XState to a team project, I’d factor in the learning curve for everyone involved. It’s probably worth doing some basic training, at least.