Guidelines for State Machines and XState
Table of Contents
- Intro
- What is a State Machine?
- Why Booleans are a Misguided Approach
- Side Effects with State Machines
- Context in State Machines
- Guards and Conditions
- Function Components & Hooks
- Class Components
- Complex Machines
- Visualizing State Machines
- How to Approach Creating a State Machine
- Testing
- Resources
Intro
This exists as a living document to define some guidelines regarding state machines and their usage. These guidelines are by no means exhaustive, but hopefully set you on the right path. It is highly recommended that you read the XState docs for more information.
What is a State Machine?
A state machine, more specifically a finite state machine, is a means of representing all the possible enumerated states of a system and the possible enumerated transitions between states in that system. That was a bit jargon heavy, so let’s use a brief example to illuminate what this means.
Consider a light bulb. A light bulb typically has two states: lit
and unlit
. When we have two states, we are often tempted to represent this state with a boolean, true
or false
, but often this is a misguided approach to state management. We will explore why in the next section. Instead, let’s define these two states as objects.
const lit = {}
const unlit = {}
const states = {
lit,
unlit,
}
Next, let’s define the possible transitions for each state to the other states. I will follow the conventions of the XState library, in order to introduce the API to you.
const lit = {
on: { TURN_OFF: 'unlit' },
}
const unlit = {
on: { TURN_ON: 'lit' },
}
We define “events” on the on
property of a state node in XState (and by convention, they are in scream case 😱). Machines respond to these events, but only when the current state has an enumerated event of the same type. In the context of this example, if an event of type: 'TURN_OFF'
is sent to the machine, it will respond and transitions states only when we are in the lit
state. Sending the TURN_OFF
event to a light bulb that’s in the unlit
state will have no effect.
In this example, our events use the common shorthand of EVENT_NAME: 'nextStateName'
In long form, this is equivalent to EVENT_NAME: { target: 'nextStateName' }
. Use the shorthand if you do not need to add any “actions” or “conditions”, the longhand otherwise. “Actions” and “conditions” will be covered later in these guidelines.
Let’s combine what we’ve written so far into an actual XState machine. Machines receive an id
, an initial
state, and a states
object of the enumerated state nodes.
import { Machine } from 'xstate'
const lit = {
on: { TURN_OFF: 'unlit' },
}
const unlit = {
on: { TURN_ON: 'lit' },
}
const states = {
lit,
unlit,
}
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states,
})
Let’s move our lit
and unlit
states directly into the Machine
config object to tidy this up:
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
on: { TURN_OFF: 'unlit' },
},
unlit: {
on: { TURN_ON: 'lit' },
},
},
})
Every machine has a transition
method, which receives a state
and an event
and returns the next state. This is very similar to a reducer for Flux or Redux.
lightBulbMachine.transition('lit', 'TURN_OFF').value // 'unlit'
lightBulbMachine.transition('unlit', 'TURN_OFF').value // 'unlit'
lightBulbMachine.transition('unlit', 'TURN_ON').value // 'lit'
Using the transition
method explicitly is not common for most state machine usage with XState. Instead, a service
is used to maintain the current state of the machine and send
events to the machine. This will be discussed further, specifically in the section on Class Components
Why Booleans are a Misguided Approach
Looking more closely at our light bulb, one might realize that there is actually a third state that a light bulb can be in: broken
.
A broken
light bulb is different than an unlit
light bulb and deserves its own state. If we were attempting to model a light bulb with these three states using booleans, we’ll discover a problem right away.
Most likely we would use two booleans to cover the possible states: isLit
and isBroken
. Since we are representing these states with booleans, each state has two possible outcomes. We can calculate the possible state permutations with the equation 2^n
, where n
is the number of states (2
because the enumerated states of a boolean is 2
: true
and false
). Thus, trying to cover our light bulb with two booleans reveals that we’ve actually created four states.
isLit | isBroken |
---|---|
false | false |
false | true |
true | false |
true | true |
But we only have three states in our model? Where did this fourth state come from? This fourth state is an “impossible state”, a state our system should not be capable of getting to. It occurs when we have isLit && isBroken
.
Now, we’re decent programmers and can typically code around this with guards and conditional logic, but as we start to add booleans to manage state, that explosion of possible permutations continues at a rapid pace. In just a few iterations, the permutations grow: 2, 4, 8, 16, 32, 64, 128, etc.
State machines avoid this problem by forcing us to enumerate only the possible states and transitions, which more accurately models what we are trying to achieve anyways. Making an accurate light bulb devoid of impossible states is as simple as adding a new state node and some events to respond to.
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
on: {
TURN_OFF: 'unlit',
BREAK: 'broken',
},
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken',
},
},
broken: {
type: 'final',
},
},
})
For a bonus touch, we add the type: 'final
to the broken
state. It is encouraged that you add this to any final state in your machine, though it is not necessary. It is useful in more complex situations where machines invoke other machines. That will not be covered in this guideline. Please refer to the XState docs about Invoking Services.
Side Effects with State Machines
State machines respond to events. Events may or may not trigger a transition. As a result of transitions, we have the opportunity to produce side effects. These side effects are called “actions”.
We can call actions at three moments in the state transition cycle, and they happen in this order.
- on
exit
from a state - on the transition to a state
- on
entry
to a state
Let’s fire actions of each kind in our light bulb example.
import { Machine } from 'xstate'
// We can break pieces of our chart out and compose them together
// just as we would any other object
const breakEvent = {
BREAK: {
// BREAK: 'broken' is a shorthand for BREAK: { target: 'broken' }
target: 'broken',
actions: ['informUserOfBrokeLightBulb'],
},
}
const lightBulbMachine = Machine(
{
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
entry: 'enterLit', // If only a single action is taken, a string will suffice
on: {
TURN_OFF: 'unlit',
...breakEvent,
},
},
unlit: {
exit: ['leaveUnlit'],
on: {
TURN_ON: 'lit',
...breakEvent,
},
},
broken: {
type: 'final',
},
},
},
{
actions: {
// all actions receive the machine's `context` and the `event` that triggered the action.
// More on context later
enterLit: (context, event) => {
console.log(`Light bulb lit from ${JSON.stringify(event)}`)
},
leaveUnlit: () => {
console.log('We can do more than log stuff, this is just an example')
},
informUserOfBrokeLightBulb: (_, event) => {
// we can put more information on an event object, such as
// { type: 'BREAK', bulbId: 'someUUIDOrSomething' }
contactUserAboutBrokenBulb(event.bulbId)
},
},
},
)
We have defined all our actions as strings and made matching methods in the second argument to Machine()
. This is the preferred way to add actions to machines. You can write functions inline in actions
or entry
or exit
, however, following the guideline of using strings will be especially useful when we start combining XState and React.
Context in State Machines
The concept of context
has a different meaning in state machines than it does in React. In React, context
is a state held in closure to be referenced by many components via a providing and consuming component architecture. It is used to avoid excessive “prop drilling” among other things.
In state machines, context
is used to store data that cannot be easily represented by finite states. In fact, it is most often used to keep track of infinite states.
Consider a text input. There exists an infinite number of strings that could be used as the value
of that input. It therefore would require an infinite number of states to model that system. This is simply impossible.
That said, occasionally we need values that result in infinite states in order to accomplish a variety of tasks. Thus, context
in state machines.
Think of context
in a state machine similar to how you think of state
in React. It is localized to the machine. It can be updated when necessary and can be referenced by the machine.
context
is added as a context
object on the config
object of a machine. Let’s take our light bulb example and add a color
to it in context.
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
// We'll add `context` here
context: {
color: '#fff',
},
states: {
lit: {
on: {
TURN_OFF: 'unlit',
BREAK: 'broken',
},
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken',
},
},
broken: {
type: 'final',
},
},
})
We’ve added color
to context. Now, let’s create an event to update the color
.
import { assign, Machine } from 'xstate'
const lightBulbMachine = Machine(
{
id: 'lightBulb',
initial: 'unlit',
// We'll add `context` here
context: {
color: '#fff',
},
states: {
lit: {
on: {
CHANGE_COLOR: {
actions: ['updateColor'],
},
TURN_OFF: 'unlit',
BREAK: 'broken',
},
},
unlit: {
on: {
CHANGE_COLOR: {
actions: ['updateColor'],
},
TURN_ON: 'lit',
BREAK: 'broken',
},
},
broken: {
type: 'final',
},
},
},
{
actions: {
updateColor: assign((context, event) => ({
color: event.color,
})),
},
},
)
We’ve added a CHANGE_COLOR
event to the lit
and unlit
states. We can send a color
value on an event object. The machine responds to the event, calling the actions for that event. The action in this case is updateColor
, which is a special action known as an assign
.
assign
is a action creator function for XState used specifically to update context
. It works very similar to React’s setState
. It has two APIs that will be familiar to React developers:
assign
can take an object as an argument, merging it with the currentcontext
to get thenextContext
assign({
color: '#f00',
})
- It can receive an updater function that returns an object to be merged with the current
context
to get thenextContext
. This updater function receives the currentcontext
and theevent
as arguments.
assign((context, event) => ({
color: event.color,
}))
- A third API exists that is different from
React.setState
, an object whose values are function updaters
assign({
color: (context, event) => event.color,
})
Any of these APIs is fine to use. In some ways, it is simpler than React.setState
as there are no async issues to concern yourself with. There is one “gotcha” to understand about assign
s though. All assign
s are run before other actions. This is simple to understand when you consider that Machine.transition
is a pure function that receives a state
, event
and context
to derive the nextState. In order to give it the correct context
, assign
s must be run to determine the next state. Read more about this in the XState docs under Context - Action Order.
Now that we understand assign
actions, let’s use it on our light bulb machine to change the color.
const nextState = lightBulbMachine.transition('lit', {
type: 'CHANGE_COLOR',
color: '#f00',
}) // { value: 'lit', context: { color: '#f00' } }
Take notice that we have an event that only triggers actions. It does not have a target
state. This is useful when we want to respond to events in a state without making a state transition.
Guards and Conditions
It won’t be long before you come to a point where you would like to make a transition to a state conditional. This is accomplished through the use of a guard
function on the cond
property of a state node.
Let’s consider a fancier light bulb, one with a “locking mechanism” that prevents a user from turning it on. Perhaps it’s a child’s bedside lamp and we’d like to prevent them from reading during the night. We can create a condition that prevents the transition to the lit
state to model this.
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
on: {
TURN_OFF: 'unlit',
BREAK: 'broken'
}
},
unlit: {
on: {
TURN_ON: {
target: 'lit',
cond: 'isDaylightHours'
],
BREAK: 'broken'
}
},
broken: {
type: 'final'
}
}
}, {
guards: {
isDaylightHours: (context, event) => {
return event.time.getHours() > 7 || event.time.getHours() < 21
}
}
})
We’ve created a guard
function named isDaylightHours
. It’s a predicate function (a function that returns a boolean). We’ve referenced that function on the cond
property of the TURN_ON
event in the unlit
state. If the guard returns true
, the transition is taken to lit
, otherwise it will remain unlit
.
Guards are a simple, yet effective way to model conditional state transitions. Be wary if your guard is really a child state of a parent state in disguise. This refers to hierarchical states, which will be covered in the Hierarchical Machines section.
Function Components & Hooks
The @xstate/react
package ships a very useful hook, useMachine
, that can be used in function components to easily instantiate a service for a machine. useMachine
accepts a Machine
as its first argument and an options
object as its second object. In this way, we’re able to create a machine that defines our component’s enumerated states and transitions outside the scope of the function, but use any props or functions in the scope of the component within the options
object.
import React from 'react'
import {Machine} from 'xstate'
import {useMachine} from '@xstate/react'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
on: {
TURN_OFF: 'unlit',
BREAK: 'broken'
}
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken'
}
},
broken: {
type: 'final'
entry: ['logBroken']
}
}
})
const TEXTS_BY_STATE = {
lit: 'LIGHT!',
unlit: 'not light',
broken: 'so borked'
}
function LightBulb({ id }) {
const [state, send] = useMachine(lightBulbMachine, {
actions: {
logBroken: () => {
console.log(`Light bulb with id: ${id} has broken`)
}
}
})
return (
<div>
{TEXTS_BY_STATE[state.value]}
<div>
<button onClick={() => send('TURN_ON')} type="button">Turn On</button>
<button onClick={() => send('TURN_OFF')} type="button">Turn Off</button>
<button onClick={() => send('BREAK')} type="button">Break</button>
</div>
</div>
)
}
In the above example, we render all of the buttons regardless of what state.value
is. We can disable buttons based on the current state with state.matches()
.
state.matches()
accepts a string or object representing a state and returns a boolean regarding if it matches or not. We can use it like this:
return (
<div>
{TEXTS_BY_STATE[state.value]}
<div>
<button
disabled={state.matches('lit')}
onClick={() => send('TURN_ON')}
type="button"
>
Turn On
</button>
<button
disabled={state.matches('unlit')}
onClick={() => send('TURN_OFF')}
type="button"
>
Turn Off
</button>
<button
disabled={state.matches('broken')}
onClick={() => send('BREAK')}
type="button"
>
Break
</button>
</div>
</div>
)
We can extrapolate this API, the use of state.value
and state.matches()
to handle all condition based rendering and functionality in our components and more.
Class Components
It is possible to use XState with class components as well, but this will require a few extra steps and a new concept. The concept first.
A state machine, specifically the transition
method on a machine, is a pure function. We give it a state
and an event
(and we can supply a context
as the third argument) and we should get the same next state every time. Being pure means that the machine itself does not store the current state. Instead, we are responsible for that. We can accomplish this through using a service
.
A service
is used to maintain the current state. We also send
events to the service
(hence where send
comes from with the useMachine
hook) which are passed along the machine’s transition
method internally. We can start a service by using the interpret
function from XState. Let’s write our light bulb component as a class component.
import React from 'react'
import {Machine, interpret} from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
on: {
TURN_OFF: 'unlit',
BREAK: 'broken'
}
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken'
}
},
broken: {
type: 'final'
entry: ['logBroken']
}
}
})
const TEXTS_BY_STATE = {
lit: 'LIGHT!',
unlit: 'not light',
broken: 'so borked'
}
class LightBulb extends React.Component {
state = {
current: lightBulbMachine.initialState
}
config = {
actions: {
logBroken: () => {
const { id } = this.props
console.log(`Light bulb with id: ${id} has broken`)
}
}
}
service = interpret(lightBulbMachine.withConfig(this.config))
.onTransition(current => {
this.setState({ current })
})
componentDidMount() {
this.service.start()
}
componentWillUnmount() {
this.service.stop()
}
render() {
const { current } = this.state
const { send } = this.service
return (
<div>
{TEXTS_BY_STATE[current.value]}
<div>
<button onClick={() => send('TURN_ON')} type="button">Turn On</button>
<button onClick={() => send('TURN_OFF')} type="button">Turn Off</button>
<button onClick={() => send('BREAK')} type="button">Break</button>
</div>
</div>
)
}
}
I want you to take notice of a few key things:
- We need to
start
andstop
our service. This is because in complex state machines, thereactivities
and long running side effects that depend on these methods being called. - We can configure our state machine with local
props
and methods, but we must do so by extending the machine using thewithConfig
method. This is different than theuseMachine
hook, where we could supply a second argument to the function instead. - We store the
current
state in local React state. We use theservice
’sonTransition
method to keep state in sync in our component.
While it is more code, it does mean that state machines can be introduced to legacy components without the need to update them to function components that can use the useMachine
hook.
Complex Machines
So far, our state machines have been relatively simple. They have been one level of states and one single system of states. At some point, it is likely you’ll need something more complex. It is highly recommended you read the XState docs for a deep dive on more complex machines, but two common ones will be covered here: Hierarchical and Parallel machines.
Hierarchical Machines
As you’re developing a machine, you may realize that some of your enumerated states are really child states of a parent state. Let’s extend our light bulb example further.
Our light bulb has a new feature, it can shine at three different levels of brightness: low
, normal
, and high
. These states are child states (or “substates”) of our lit
state. Let’s write a hierarchical state machine to convey this.
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
// Child states start here
initial: 'normal',
states: {
low: {
on: {
INCREASE_BRIGHTNESS: 'normal',
},
},
normal: {
on: {
DECREASE_BRIGHTNESS: 'low',
INCREASE_BRIGHTNESS: 'high',
},
},
high: {
on: {
DECREASE_BRIGHTNESS: 'normal',
},
},
},
// Child states end here
on: {
TURN_OFF: 'unlit',
BREAK: 'broken',
},
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken',
},
},
broken: {
type: 'final',
},
},
})
To create a hierarchical machine, we essentially create the same structure as a normal state machine, but nest it in the parent state. We add an initial
state and enumerate the child states under the states
property.
Now if our light bulb is in the lit
state, it will initially start in the { lit: 'normal' }
state. We can send events to our machine to change the brightness.
lightBulbMachine({ lit: 'normal' }, 'INCREASE_BRIGHTNESS').value // { lit: 'high' }
lightBulbMachine({ lit: 'high' }, 'DECREASE_BRIGHTNESS').value // { lit: 'normal' }
Hierarchical states are an excellent way to model states that should only exist as a child state of another state. This allows us to avoid a lot of conditional logic since our machine won’t respond to events unless it is in the correct state.
Parallel Machines
Occasionally, you’ll find your model requires a machine be in multiple states simultaneously. This is what a parallel machine is for. A parallel machine allows a machine to be in multiple states simultaneously and independently. Let’s extend our light bulb further and turn it into one for the dance floor. Lights on the dance floor flash in various patterns, and they can oscillate in different ways. We’ll focus on just those two parallel states in our example (which will also include a hierarchical state since these can only occur in the lit
state).
import { Machine } from 'xstate'
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
// parallel child states start here
type: 'parallel', // no initial state, requires `type: 'parallel'` be set
states: {
// 1st parallel state
pattern: {
// We can easily have hierarchical child states inside our parent parallel state
initial: 'steady',
states: {
steady: {
on: {
PULSE: 'pulsing',
FLASH: 'flashing',
},
},
pulsing: {
on: {
STEADY: 'steady',
FLASH: 'flashing',
},
},
flashing: {
on: {
STEADY: 'steady',
PULSE: 'pulsing',
},
},
},
},
// 2nd parallel state
movement: {
initial: 'stationary',
states: {
stationary: {
on: { OSCILLATE: 'oscillating' },
},
oscillating: {
on: { STOP: 'stationary' },
},
},
},
},
// parallel child states end here
on: {
TURN_OFF: 'unlit',
BREAK: 'broken',
},
},
unlit: {
on: {
TURN_ON: 'lit',
BREAK: 'broken',
},
},
broken: {
type: 'final',
},
},
})
We added quite a bit of complexity here, but it’s just the combination of parallel and hierarchical states that make it happen. Let’s break it down.
Under the lit
state, we add a couple parallel states: one for the light pattern
and one for the movement
of the bulb. The bulb is in both states simultaneously and independently. We can send events that affect one without affecting the other.
Next, inside of each parallel state, we’ve added a hierarchical state chart to govern the states held within each parallel state. This is precisely what we’ve seen thus far. We have a subchart that controls the pattern
states, and one that governs the movement
states.
If we were to examine the value returned by the lit
state, it would initially look like this:
lightBulbMachine.transition('unlit', 'TURN_ON')
// {
// value: {
// lit: {
// pattern: 'steady',
// movement: 'stationary',
// }
// }
// }
Notice that the lit
state is an object with the states of all our parallel states contained within.
Visualizing State Machines
XState has a very handy visualizer at xstate.js.org/viz. You can save machines as gists from the visualizer as well.
How to Approach Creating a State Machine
Here is a general heuristic of how to approach creating a state machine.
- Enumerate the states of your program
- Enumerate the transitions between those states
- Examine if any of the states can only exist as a substate of another state. If so, know you can use a hierarchical state machine.
- Examine if any of the states can exist simultaneously and independently. If so, know you can use a parallel state machine.
- Create objects for all of your states
- Add the transitions to the
on
property of those states - Combine these into the
states
property of your machine - Add the
initial
state to your machine - Add an
id
to your machine - Start the service of your machine through either
interpret
oruseMachine
depending on your situation. - Profit.
Testing
Tests can be written just as any other test would be written regarding UI interactions to verify expected behavior.
There is also the @xstate/test
library that can automatically generate all possible branches of your state machine. More will be added to this section in the future.
Resources
- XState Docs
- XState Viz
- Introduction to State Machines and XState on egghead.io (if you have an egghead account. Access for Webflow engineers will come soon.)