Learning State Machines and State Charts with XState
- 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.
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 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.
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