Modeling UI Logic with XState State Machines

State machines represent a model that governs a predetermined set of states and the events that trigger movement between them. These abstractions allow us to define a clear path for our application rather than defending against a vast number of possible paths.

Consider a traffic light state machine: it encompasses three states—red, yellow, and green. Green leads to yellow, yellow leads to red, and red cycles back to green. When state machines govern our logic, transitioning from red to yellow or yellow to green becomes impossible.

To understand how state machines can significantly reduce complexity in our UI, business logic, and code, let's examine the following scenario:

Imagine a light bulb that exists in one of three possible states: lit, unlit, or broken. Three buttons enable us to switch the bulb on, off, or damage it. The HTML structure for this is shown below:

<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'll capture references to our button elements and attach click event listeners to implement our core 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"
})

Once a light bulb is broken, transitioning it back to the lit state should be prohibited. However, our initial implementation allows this with a simple button click. To prevent this, we must safeguard against moving from the broken state to the lit state. Typically, this involves using boolean flags or performing verbose checks prior to each action, like this:

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
})

However, such checks frequently lead to unpredictable behavior in our applications: forms that display success messages despite failing submission, or queries that run when they shouldn't. Managing boolean flags is highly error-prone and difficult to read or reason about. Moreover, our logic becomes distributed across various scopes and functions, making refactoring a cross-file endeavor that heightens the risk of introducing bugs.

State machines address this challenge perfectly. By establishing the application's behavior upfront, the UI becomes a straightforward reflection of that logic. Let's refactor our initial approach to incorporate state machines.

Understanding States and Events

We'll define our machine as a straightforward object that catalogs potential states and their corresponding transitions. Some state machine libraries utilize more intricate objects to accommodate additional functionalities.

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

The following diagram illustrates our state machine:

Our machine object contains an initial property to establish the starting state upon app launch, along with a states object that enumerates every possible state. These individual states optionally include an on object defining the events they respond to. For instance, the lit state reacts to OFF and BREAK events. This means when the bulb is lit, options include turning it off or breaking it. Attempting to turn it on while already on is not permitted by the model (though we could design logic to allow this if preferred).

Final States

Since the broken state doesn't react to any events, it qualifies as a final state—one that doesn't transition elsewhere, thereby terminating the entire state machine's flow.

Transitions

A transition acts as a pure function that generates a new state based on the current state and a given event.

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

Our transition function navigates through the machine's states and their associated 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)

When a specific event is not handled in a given state, our state remains unchanged and we return the existing state. This fundamental principle is central to state machines.

Event Tracking and Dispatch

To monitor and dispatch events, we'll set up a state variable initialized to the machine's starting state, alongside a send function that processes an event and updates our state according to the machine's configuration.

let state = machine.initial

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

Instead of managing state with booleans and conditional statements, we now simply dispatch our events and trigger a UI refresh:

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

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

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

All three buttons function as expected, and once the light bulb breaks, users are prevented from turning it on or off.

Implementing State Machines with XState

XState is a state management library that has championed state machine adoption on the web recently. It offers tools for creating, interpreting, and subscribing to state machines, along with features for guarding and deferring events, managing extended state, and more.

To get started, install XState using npm install xstate.

XState provides two key functions for building and driving 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 streamline the code further by subscribing to state changes and updating the UI reactively within the 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
})

Observe that we now utilize state.value instead of state. This is because XState attaches numerous helpful methods and properties to the state object, including state.matches:

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

Another valuable method is state.can, which determines whether the current state can handle a specific event, returning a boolean.

With this, we can initially conceal the Turn on button and dynamically control button visibility based on whether their associated events are dispatchable:

<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")
})

This approach allows us to selectively display buttons in response to each state change.

Actions and Side Effects

XState supports three action types for executing side effects within a machine: transition actions that respond to events, entry actions triggered upon entering a state, and exit actions invoked upon leaving it. The entry, exit, and actions properties can each accept an array of functions (or string references, which we'll explore shortly).

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 discussing state machines, it's useful to distinguish between finite state and extended state.

For example, a person can be standing or sitting, but not both simultaneously. They might also be awake or asleep, among other finite categories. Conversely, a person may possess potentially limitless attributes, such as age, nicknames, or hobbies—this is known as extended state. Conceptually, finite state describes qualitative aspects while extended state captures quantitative attributes.

In our example, we'll monitor how many times the light bulb switches On or Off (as extended state) and display this count 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've now introduced a context property containing a switchCount, initialized to 0. We've also attached entry actions to the lit and unlit states using string references and defined these functions through the machine's withConfig method, eliminating code duplication.

To modify the context within the machine, XState offers the assign function. This function creates an action and cannot be nested inside a regular function (for instance, actions: () => { assign(...) } is not permitted).

Additionally, despite the default switchCount value of 0, XState executes our entry action upon service initialization, resulting in a displayed count of 1 on screen.

Guards

Guards enable us to prevent a transition when a certain condition isn't met (like validation results). While guards apply only to events, they offer the same definitional flexibility as actions.

For illustration, we'll block attempts to break a light bulb once 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,
  },
})

Guards are integrated into events via the uniquely named cond property (an abbreviation for condition). We then write our goodLightBulb guard definition within the withConfig guards section.

Earlier, service.can runs these guards to assess whether an event can be dispatched; consequently, our UI correctly removes the break button once the condition is satisfied. If the guard function encounters an issue, service.can will return false.

Eventless Transitions

Suppose we need to enforce that regardless of bulb quality, it must break once the switch count hits 10.

We can implement this with an eventless transition using the always property combined 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: {},
  },
})

Additional Exploration

While we've focused on employing state machines to model our UI, they are versatile and utilized across various domains—from managing data-fetching, loading, and error states to crafting complex interactive animations.

Throughout this discussion, we've examined core state machine principles, including states, events, and transitions. We've also successfully built a machine with XState, integrating extended state, actions, guarded and eventless transitions. Though we manually updated our UI by subscribing to the machine's service, XState provides integrations for nearly all major frontend frameworks.