{"community_link":"https://github.com/indepth-dev/community/discussions/213"}

​State Machines in JavaScript with XState

In this article, we will learn about State Machines in Javascript with XState.

​State Machines in JavaScript with XState

State machines are models that govern a finite number of states and the events that transition from one state to the other. They are abstractions that allows us to explicitly define our application's path instead of guarding against a million possible paths.

A state machine for traffic lights, for instance, would hold three states: red, yellow, and green. Green transitions to yellow, yellow to red, and red back to green. Having a state machine define our logic makes it impossible to have an illegal transition from red to yellow or yellow to green.

To see how state machines can vastly reduce the complexity of our UI, business logic, and code, we’ll take the following example:

We have a light bulb that can be either lit, unlit, or broken and three buttons that can turn the bulb on or off, or break it, as represented by the following HTML code:

<p>The light bulb is <span id="lightbulb">lit</span></p>

<button id='turn-on'>turn on</button>
<button id='turn-off'>turn off</button>
<button id='break'>break</button>

​We will reference our button elements and add click event listeners to implement our business logic.

const lightbulb = document.getElementById("lightbulb")

const turnBulbOn = document.getElementById("turn-on")
const turnBulbOff = document.getElementById("turn-off")
const breakBulb = document.getElementById("break")

turnBulbOn.addEventListener("click", () => {
  lightbulb.innerText = "lit"
})

turnBulbOff.addEventListener("click", () => {
  lightbulb.innerText = "unlit"
})

breakBulb.addEventListener("click", () => {
  lightbulb.innerText = "broken"
})

​If the light bulb is broken, turning it on again should be an impossible state transition. But the implementation above allows us to do that by simply clicking the button. So we need to guard against the transition from the broken state to the lit state. To do that, we often resort to boolean flags or verbose checks before each action, as such:

let isBroken = false

turnBulbOn.addEventListener("click", () => {
  if (!isBroken) {
    lightbulb.innerText = "lit"
  }
})

turnBulbOff.addEventListener("click", () => {
  if (!isBroken) {
    lightbulb.innerText = "unlit"
  }
})

breakBulb.addEventListener("click", () => {
  lightbulb.innerText = "broken"
  isBroken = true
})

​But these checks are often the cause of undefined behavior in our apps: forms that submit unsuccessfully yet show a success message, or a query that fires when it shouldn’t. Writing boolean flags is extremely error-prone and not easy to read and reason about. Not to mention that our logic is now spread across multiple scopes and functions. Refactoring this behavior, would mean refactoring multiple functions or files, making it more error-prone.

​This is where state machines fit perfectly. By defining your app’s behavior upfront, your UI becomes a mere reflection of your app’s logic. So let’s refactor our initial implementation to use state machines.

​States and events

​We will define our machine as a simple object, that describes possible states and their transitions. Many state machine libraries resort to more complex objects to support more features.

const machine = {
  initial: "lit",
  states: {
    lit: {
      on: {
        OFF: "unlit",
        BREAK: "broken",
      },
    },
    unlit: {
      on: {
        ON: "lit",
        BREAK: "broken",
      },
    },
    broken: {},
  },
}

​Here’s a visualization of our state machine:

Our machine object comprises of an initial property to set the initial state when we first open our app, and a states objects holding every possible state our app can be in. These states in turn have an optional on object spelling out the events  that they react to. The lit state reacts to an OFF and BREAK events. Meaning that when the light bulb is lit, we can either turn it off or break it. We cannot turn it on while it is on (although we can model our logic that way if we choose to.)

​Final States

The broken state does not react to any event which makes it a final state — a state that does not transition to other states, ending the flow of the whole state machine.

Transitions

​A transition is a pure function that returns a new state based on the current state and an event.

const transition = (state, event) => {
  const nextState = machine.states[state]?.on?.[event]
  return nextState || state
}

​The transition function traverses our machine’s states and their events:

transition('lit', 'OFF')      // returns 'unlit'
transition('lit', 'BREAK')    // returns 'broken'
transition('unlit', 'OFF')    // returns 'unlit' (unchanged)
transition('broken', 'BREAK') // return  'broken' (unchanged)
transition('broken', 'OFF')   // returns 'broken' (unchanged)

​If we are not handling a specific event in a given state, our state is going to remain unchanged, returning the old state. This is a key idea behind state machines.

​Tracking and sending events

To track and send events, we will define a state variable that defaults to our machine’s initial state and a send function that takes an event and updates our state according to our machine’s implementation.

let state = machine.initial

const send = (event) => {
  state = transition(state, event)
}

So, now instead of keeping up with our state through booleans and if statements, we simply dispatch our events and call for our UI to update:

turnBulbOn.addEventListener("click", () => {
  send("ON")
  lightbulb.innerText = state
})

turnBulbOff.addEventListener("click", () => {
  send("OFF")
  lightbulb.innerText = state
})

breakBulb.addEventListener("click", () => {
  send("BREAK")
  lightbulb.innerText = state
})

Our three buttons will work as intended, and whenever we break our light bulb, the user will not be able to turn it on or off.

State machines using XState

XState is a state management library that popularized the use of state machines on the web in recent years. It comes with tools to create, interpret, and subscribe to state machines, guard and delay events, handle extended state, and many other features.

To install XState, run npm install xstate.

XState provides us with two functions to create and manage (track, send events, etc.) our machines: createMachine and interpret.

import { createMachine, interpret } from "xstate"

const machine = createMachine({
  initial: "lit",
  states: {
    lit: {
      on: {
        OFF: "unlit",
        BREAK: "broken",
      },
    },
    unlit: {
      on: {
        ON: "lit",
        BREAK: "broken",
      },
    },
    broken: {},
  },
})

const service = interpret(machine)
service.start()

turnBulbOn.addEventListener("click", () => {
  service.send("ON")
  lightbulb.innerText = service.state.value
})

turnBulbOff.addEventListener("click", () => {
  service.send("OFF")
  lightbulb.innerText = service.state.value
})

breakBulb.addEventListener("click", () => {
  service.send("BREAK")
  lightbulb.innerText = service.state.value
})

We can minimize our code further by subscribing to our state and updating the UI as a subscription:

turnBulbOn.addEventListener("click", () => {
  service.send("ON")
})

turnBulbOff.addEventListener("click", () => {
  service.send("OFF")
})

breakBulb.addEventListener("click", () => {
  service.send("BREAK")
})

service.subscribe((state) => {
  lightbulb.innerText = state.value
})

You notice that we’re now using state.value instead of state, because XState exposes a number of useful methods and properties on the state object, one of which is state.matches:

state.matches('lit') // true or false based on the current state
state.matches('non-existent-state') // false

One other useful state method is the state.can method, which returns true or false based on whether the current state handles a given event or not.

Thus, we can initially hide our Turn on button and show/hide our buttons based on whether we can dispatch their related events:

<p>The lightbulb is <span id="lightbulb">lit</span></p>

<!-- hidden on page load -->
<button hidden id="turn-on">Turn on</button>

<button id="turn-off">Turn off</button>
<button id="break">Break</button>

<!-- reloads the page -->
<button hidden id="reset" onclick="history.go(0)">Reset</button>
const lightbulb = document.getElementById("lightbulb")
const turnBulbOn = document.getElementById("turn-on")
const turnBulbOff = document.getElementById("turn-off")
const breakBulb = document.getElementById("break")
const reset = document.getElementById("reset")

service.subscribe((state) => {
  lightbulb.innerText = state.value

  turnBulbOn.hidden = !state.can("ON")
  turnBulbOff.hidden = !state.can("OFF")
  breakBulb.hidden = !state.can("BREAK")

  reset.hidden = !state.matches("broken")
})

So now, whenever our state changes, we can show and hide the appropriate buttons.

Actions and side effects

To run side effects inside our machine, XState has three types of actions: transition actions that run on an event, entry actions that run when we enter a state, and exit actions that run when we exist a state. entry, exit, and actions can all be an array of functions (or even string references as we will see.)

const machine = createMachine({
  initial: "open",
  states: {
    open: {
      entry: () => console.log("entering open..."),
      exit: () => console.log("exiting open..."),
      on: {
        TOGGLE: {
          target: "close",
          actions: () => console.log("toggling..."),
        },
      },
    },
    close: {},
  },
})

Context and extended state

When talking about state machines, we can distinguish between two types of states: finite state and extended state.

A person, for instance, can be either standing or sitting. They cannot be standing and sitting at the same time. They also can be either awake or asleep, and an array of other finite states. By contrast, a person can also have state that’s potentially infinite. E.g., their age, nicknames, or hobbies. This is called infinite or extended state. It helps to think about finite state as qualitative state while extended state is quantitative.

In our example, we will track how many times we switch our light bulb (as extended state) and display it in our message.

import { assign, createMachine, interpret } from "xstate"

const machine = createMachine({
  initial: "lit",
  context: { switchCount: 0 },
  states: {
    lit: {
      entry: "switched",
      on: {
        OFF: "unlit",
        BREAK: "broken",
      },
    },
    unlit: {
      entry: "switched",
      on: {
        ON: "lit",
        BREAK: "broken",
      },
    },
    broken: {},
  },
}).withConfig({
  actions: {
    switched: assign({ switchCount: (context) => context.switchCount + 1 }),
  },
})

const service = interpret(machine)
service.start()

service.subscribe((state) => {
  lightbulb.innerText = `${state.value} (${state.context.switchCount})`

  turnBulbOn.hidden = !state.can("ON")
  turnBulbOff.hidden = !state.can("OFF")
  breakBulb.hidden = !state.can("BREAK")

  reset.hidden = !state.matches("broken")
})

We now have a context property that holds a switchCount initiated with 0. And as mentioned before, we added entry actions to our lit and unlit states using string references and defined our functions using the withConfig method on our machine to eliminate any code repetition.

To update our context inside the machine, XState provides an assign function, which gets called to form an action and cannot be used within a function (e.g., actions: () => { assign(...) }).

Also, notice that despite our default value of 0 for our switchCount, XState will run our entry action when our service starts, displaying a count of 1 in our UI.

Guards

Guards allow us to block a transition from happening given a condition (such as the outcome of an input validation.) Unlike actions, guards only apply to events, but they enjoy the same flexibility in their definition.

In our example, we will block any attempt to break a light bulb if the switch count exceeds 3.

const machine = createMachine({
  initial: "lit",
  context: { switchCount: 0 },
  states: {
    lit: {
      entry: "switched",
      on: {
        OFF: "unlit",
        BREAK: { target: "broken", cond: "goodLightBulb" },
      },
    },
    unlit: {
      entry: "switched",
      on: {
        ON: "lit",
        BREAK: { target: "broken", cond: "goodLightBulb" },
      },
    },
    broken: {},
  },
}).withConfig({
  actions: {
    switched: assign({ switchCount: (context) => context.switchCount + 1 }),
  },
  guards: {
    goodLightBulb: (context) => context.switchCount <= 3,
  },
})

We add guards in our events using the weirdly named cond property (stands for condition), and we use the withConfig guards property to write our definition for the goodLightBulb guard.

The service.can we used earlier runs our guards to determine whether an event is possible to dispatch or not, which means that our UI will correctly remove the break button once our condition is met. If our guard function fails to run, service.can​ will return false​.

Eventless transitions

Let’s say that no matter how good our light bulb is, if the switch count reaches 10, it should break.

To achieve that, we can use an eventless transtion using the always property with a condition:

const machine = createMachine({
  initial: "lit",
  context: { switchCount: 0 },
  states: {
    lit: { /*...*/ },
    unlit: {
      entry: "switched",
      on: { /*...*/ },
      always: {
        cond: (context) => context.switchCount >= 10,
        target: "broken",
      },
    },
    broken: {},
  },
})

What’s next?

Despite covering the use of state machines to model our UI, state machines can be and are used everywhere. From handling data-fetching, loading, and error states to building complex interactive animations.

In this article, we covered key concepts behind state machines, events, and transitions, and we implemented a state machine in XState, making use of extended state, actions, guarded and eventless transitions. And while we updated our UI manually by subscribing to our machine’s service, XState supports almost all major frontend frameworks.