Learning State Machines and State Charts with XState

TL;DR
  • Finite State Machines (FSMs) describe systems that have a finite number of possible states and the possible transitions between them.
  • State charts are "extended" state machines. They add the ability to define hierarchical (nested) states, orthogonal (parallel) states, and extended state to account for data that is not finite in nature (possible input values in a text input, for example).
  • The XState library allows you to defined state charts using JavaScript objects and includes an interpreter to use the machine, use events to transition between states, and to run effects.

I'll be working through the docs, building examples, and collecting my thoughts here.

I'm going to do my best not to reconstruct the existing documentation, but some foundational information will be outlined for my own purposes when it comes time to teach and/or "sell" other devs on the concept.

Warning
These notes are my attempt to capture my understanding of XState and the best way to build applications with it. My understanding of things will evolve over time. If you're reading through this, I hope it's helpful, but I make no guarantees to the validity or accuracy of anything I've captured here. Someday, I'll probably create some more polished tutorials that will get more rigor applied.

Resources

XState Docs

XState is the library that created my awareness and interest in using state machines for building UIs. This is one of my primary sources of my information during this deep dive.

The world of state charts

This is a fantastic site full of thorough descriptions of different terminology related to FSMs and State charts

Some discussion around XState and Redux on Stack Overflow

Talk from Chrome DevSummit on architecting web apps with the Actor model

Why State Charts for UI?

  • State charts are declarative and can be serialized

  • States charts can be visualized using XState visualizer By visualizing state charts, it's easier to communicate with all members of a team and work through situations that need to be accounted for with everybody's input.

  • State charts are not a new idea. From The world of state charts

    The original paper that defines statecharts bills them as “A visual formalism for complex systems” (Harel, 1987).

  • the XState implementation of state charts is compatible with the SCXML spec

  • The state chart definition and the definition of effects can be separated.

    This allows an entire machine definition to be serialized, using key names to describe effects. Those effects can later be defined in a config when passing the machine into an interpreter.

  • The state chart is independent of your UI framework

  • We're already using state machines, but they are built up of boolean flags and logic that is error prone and typically incomplete.

  • State charts with XState work well with TypeScript

  • there is an XState package to generate your test paths through a state machine based application... changes to your app means regenerating tests and catching problems immediately 🤯.

But, but... wHat aBoUt ReDuX?

State charts have "rules"

State charts follow "rules" that don't exist in Redux. Transitions are dependent on current state, so you can restrict which events can change your state based on the state you're currently in.

For any give transition, you can also explicitly define the effects, and since the transitions are defined based on the current state, one event can have different (explicitly defined) actions depending on the two ends of a specific transition.

Essentially, you can make impossible states impossible with state machines.

Local > Global

XState is intended to be used locally, rather than requiring a single, global state store.

Global state can lead to performance issues when not handled with care.

If you're interested in more on this, I've written some notes on state management in React here

Actor model

State charts work well with the Actor model.

more to come on this as I dive deeper

Terms

State

one representation of a system at a point in time. A system can be on one of a number of states at any given time. The simplest example is of a traffic light that can be one of green, red, or yellow at any given time.

Transition

a state machine will switch, or transition, from one state to another. Like a traffic light transitioning from green to yellow, and then from yellow to red.

Event

an event in a state machine is a signal that triggers a transition. Events are "sent" to the machine and for any given state, a transition may or may not be defined for the event. When the transition for the received event is defined for the current state, then the transition will happen, and any associated effects will come with it.

Machine

The machine, or state machine is the declarative representation of the systems possible states, events, transitions, and effects.

Interpreter

The interpreter makes the declarative machine 👆 executable. It tracks the current state of the machine instance and handles transitioning in response to events. It is also responsible for executing effects.

Some small, focused examples

Toggle machine - the hello world of state machines

you could argue that a traffic light might be the hello world, but this is what I'm going with as a first example

Here I'm defining a machine that handles toggling between active and inactive states, essentially a boolean represented as a state machine:

import { Machine, interpret } from 'xstate'

const toggleMachine = Machine({
  id: 'toggle',
  initial: 'active',
  states: {
    active: {
      on: {
        TOGGLE: 'inactive',
      },
    },
    inactive: {
      on: {
        TOGGLE: 'active',
      },
    },
  },
})

The object here defines an id for the machine, the initial state and the keys in the states object define the possible states, active and inactive in this case.

Each of the states defines a transition for the TOGGLE event in its on property. inactive will transition to active and vice versa.

If an event were not defined for a particular state, the event would have no effect while in that state. So, if I didn't define the TOGGLE event for the inactive state, it would never transition out of that state, even with event being sent to the machine. Maybe it doesn't seem very useful here, but in more complex machines, having states only respond to specific events and ignore others should reduce logic needed to prevent specific behaviors while in specific states.

At this point, we have a machine defined, but it isn't executable. To take this machine and make it do things, we need an interpreter.

const machine = interpret(toggleMachine)

machine.start() // active

Passing it to interpret gives us an instance of the machine (sometimes referred to as a service), and calling start on it makes it live.

Now we can read the state from it and send events to it to trigger transitions.

To see the transitions, I'll add a onTransition handler before starting the service.

const machine = interpret(toggleMachine).onTransition((state) => {
  console.log(state.value)
})

machine.start() // active

This logs out the value of state, with the initial value of active being logged when the service is started.

Now we can trigger transitions by sending in events

machine.send('TOGGLE') // inactive
machine.send('TOGGLE') // active
machine.send('TOGGLE') // inactive

each time we send the TOGGLE event, a transition is triggered and the onTransition callback logs out the new state value.

Calling send on the service returns the updated state, so we can also do this

const updatedState = machine.send('TOGGLE') // active

and updatedState.value will be active.

The state object holds the current state in value, but also has several other keys.

You can check out the visualization of this machine here.

This whole example is in this codesandbox:

Edit toggleMachine - interpret

Hierarchical states

Sometimes, it's helpful to nest states. Let's take an example of a UI with an edit mode and a read-only mode. You could represent those states with something like this:

import { Machine, interpret } from 'xstate'

const viewMachine = Machine({
  id: 'view',
  initial: 'read',
  states: {
    read: {
      on: {
        EDIT: 'edit',
      },
    },
    edit: {
      on: {
        CANCEL: 'read',
      },
    },
  },
})

const machine = interpret(viewMachine)
  .onTransition((state) => {
    console.log(state.value)
  })
  .start()

machine.send('EDIT') // edit
machine.send('CANCEL') // read

Now let's say instead of a single "read" view, we have a tabbed interface for reading and the read view actually needs to be 3 different states. So instead of:

  • read
  • edit

We might need to represent states like:

  • read tab 1
  • read tab 2
  • read tab 3
  • edit

Let's see how that looks

import { Machine, interpret } from 'xstate'

const viewMachine = Machine({
  id: 'view',
  initial: 'readTab1',
  states: {
    readTab1: {
      on: {
        EDIT: 'edit',
      },
    },
    readTab2: {
      on: {
        EDIT: 'edit',
      },
    },
    readTab3: {
      on: {
        EDIT: 'edit',
      },
    },
    edit: {
      on: {
        CANCEL: 'readTab1',
      },
    },
  },
})

const machine = interpret(viewMachine)
  .onTransition((state) => {
    console.log(state.value) // readTab1
  })
  .start()

machine.send('EDIT') // edit
machine.send('CANCEL') // readTab1

Aside from the obviously poor naming for this contrived example, this is already starting to look a bit unwieldy. We haven't even handled navigating between tabs yet!

State charts allow us to create hierarchical, or nested states. This is probably a good place to try that out.

Let's update the code again using nested states:

import { Machine, interpret } from 'xstate'

const viewMachine = Machine({
  id: 'view',
  initial: 'read',
  states: {
    read: {
      initial: 'tab1',
      on: {
        EDIT: 'edit',
      },
      states: {
        tab1: {},
        tab2: {},
        tab3: {},
      },
    },
    edit: {
      on: {
        CANCEL: 'read',
      },
    },
  },
})

const machine = interpret(viewMachine)
  .onTransition((state) => {
    console.log(state.value) // {read: "tab1"}
  })
  .start()

machine.send('EDIT') // edit
machine.send('CANCEL') // {read: "tab1"}
machine.send('EDIT') // edit

So now we have our 3 tabs as substates of read. Note that we have an on at the top-level of read to transition from any read substate to edit.

When the machine starts and when we transition into read from edit or new state is {read: "tab1"} rather than just the string read. This is because we need to have an initial substate defined for read and we've set that to tab1 in read's initial property.

Let's add the ability to transition between the different tabs within read:

import { Machine, interpret } from 'xstate'

const viewMachine = Machine({
  id: 'view',
  initial: 'read',
  states: {
    read: {
      initial: 'tab1',
      on: {
        EDIT: 'edit',
      },
      states: {
        tab1: {
          on: {
            NEXT: 'tab2',
          },
        },
        tab2: {
          on: {
            NEXT: 'tab3',
          },
        },
        tab3: {
          on: {
            NEXT: 'tab1',
          },
        },
      },
    },
    edit: {
      on: {
        CANCEL: 'read',
      },
    },
  },
})

const machine = interpret(viewMachine)
  .onTransition((state) => {
    console.log(state.value) // {read: "tab1"}
  })
  .start()

machine.send('EDIT') // edit
machine.send('CANCEL') // {read: "tab1"}

// transition through the tabs in order
machine.send('NEXT') // {read: "tab2"}
machine.send('NEXT') // {read: "tab3"}
machine.send('NEXT') // {read: "tab1"}

I've added a NEXT event for each of the tab states. Each one defines a transition to the next state, with tab3's NEXT transitioning back to tab1.

It's important to note that we can transition from any of the read tabs to edit with the EDIT event.

machine.send('NEXT') // {read: "tab2"}
machine.send('NEXT') // {read: "tab3"}
machine.send('EDIT') // edit

Going from edit back to read will always take you to {read: "tab1"}.

This is because when we transition to read, the substate is set based on the initial property.

We can address this using another feature of state charts...

finished code for this at the end of the next section

History states!

this builds on the previous section

History states allow the machine to remember it's last state in a particular scope. So let's define a history state within read and see it in action

// preceding code left our for brevity...
  tab3: {
    on: {
      NEXT: 'tab1'
    }
  },
  hist: {
    type: 'history'
  }

Inside the read state's substates, I've added a fourth state called hist and given it the type of history. This is built into XState and this will allow the running machine to remember which read sunstate it was in when it was last in the read state.

Now to use it, we can update the transition defined in edit for the CANCEL event:

edit: {
  on: {
    CANCEL: 'read.hist'
  }
}

Now, instead of read, we transition to the hist substate of read with read.hist.

That's it, now if we transition to a read state other than tab1, we can transition to edit and then when we transition back to read.hist it will transition to the appropriate substate:

// transition through the tabs in order
machine.send('NEXT') // {read: "tab2"}
machine.send('NEXT') // {read: "tab3"}

machine.send('EDIT') // edit

// 🎉 It goes back to tab3!! 🎉
machine.send('CANCEL') // {read: "tab3"}

// then continues to work as you would expect
machine.send('NEXT') // {read: "tab1"}

You can check out the visualization of this machine here

Check out the completed example on CodeSandbox:

Edit Nested states and history state

Matching states

Let's build on the previous example and see how can determine if we're in a particular state.

The State object that is returned when we send an event has an instance method called matches. As the name implies we can use that to see if the current state matches a value.

Let's use this machine (the one from the last 2 sections)

import { Machine, interpret } from 'xstate'

const viewMachine = Machine({
  id: 'view',
  initial: 'read',
  states: {
    read: {
      initial: 'tab1',
      on: {
        EDIT: 'edit',
      },
      states: {
        tab1: {
          on: {
            NEXT: 'tab2',
          },
        },
        tab2: {
          on: {
            NEXT: 'tab3',
          },
        },
        tab3: {
          on: {
            NEXT: 'tab1',
          },
        },
        hist: {
          type: 'history',
        },
      },
    },
    edit: {
      on: {
        CANCEL: 'read.hist',
      },
    },
  },
})

With that machine defined, let's create a variable to hold our state at each change and fire up the machine with the interpreter.

let nextState

const machine = interpret(viewMachine)
  .onTransition((state) => {
    console.log(state.value) // {read: "tab1"}
  })
  .start()

nextState = machine.state

and with that, we can run matches to see if we're currently in the read state (we are)

console.log(nextState.matches('read')) // true

If we send in the EDIT event, we can see that matches returns true for edit and false for read since we've transitioned from the read state to the edit state

nextState = machine.send('EDIT')

console.log(nextState.matches('edit')) // true
console.log(nextState.matches('read')) // false

Sending the CANCEL event will send us back to read. If we match on read after, it'll match, but if we compare nextState.value to read directly with nextState.value === 'read', it will result in false.

nextState = machine.send('CANCEL') // {read: "tab1"}

console.log(nextState.matches('read')) // true

// This is false because matching isn't checking direct equality
console.log(nextState.value === 'read') // false

This is because the actual value of the state is {read: 'tab1'} because we are in a nested state. matches will match on the parent state directly, but we can also check for specific nested states. You can pass matches a string with a dot-notation string for the state you're looking for:

console.log(nextState.matches('read.tab1')) // true
console.log(nextState.matches('read.tab2')) // false

And you can also check using object notation:

// We can also check using the object notation
console.log(nextState.matches({ read: 'tab1' })) // true

And, of course, if we change to a different substate, both read and the specific substate we've transitioned to will both match:

nextState = machine.send('NEXT') // {read: "tab2"}

console.log(nextState.matches('read')) // true
console.log(nextState.matches({ read: 'tab2' })) // true

We can now:

  • Define a state chart
  • Make it executable with an interpreter
  • Send events to cause transitions
  • Match the current state against a value, using either a string or an object

Next, let's take all of this and see how it can be applied to UIs with React and XState React

Driving a React UI with XState

Let's use the same viewMachine example and see how we can apply what we know so far to a UI.

We'll put that in it's own module:

// viewMachine.js
import { Machine } from 'xstate'

export const viewMachine = Machine({
  id: 'view',
  initial: 'read',
  states: {
    read: {
      initial: 'tab1',
      on: {
        EDIT: 'edit',
      },
      states: {
        tab1: {
          on: {
            NEXT: 'tab2',
          },
        },
        tab2: {
          on: {
            NEXT: 'tab3',
          },
        },
        tab3: {
          on: {
            NEXT: 'tab1',
          },
        },
        hist: {
          type: 'history',
        },
      },
    },
    edit: {
      on: {
        CANCEL: 'read.hist',
      },
    },
  },
})

and then in the root React component, we'll import the viewMachine, along with the useMachine hook from @xstate/react. We'll wire everything up and just display a JSON stringified version of the initial state just to be sure it's all working.

import React from 'react'
import ReactDOM from 'react-dom'
import { useMachine } from '@xstate/react'
import { viewMachine } from './viewMachine'

import './styles.css'

function App() {
  const [current, send] = useMachine(viewMachine)
  return (
    <div className="App">
      <h1>XState driven UI</h1>
      {JSON.stringify(current.value)}
    </div>
  )
}

const rootElement = document.getElementById('root')
ReactDOM.render(<App />, rootElement)

With that working, let's create components that we'll display for each of the top-level states in our machine.

function EditForm() {
  return <div>edit form goes here</div>
}

function ViewTabs() {
  return <div>ViewTabs go here</div>
}

And let's display these conditionally, based on the state of the machine:

// TODO

We can test the edit condition by temporarily changing the machine's initial state to edit

// TODO