Introduction to State Machines Using XState
Introduction to State Machines Using XState
Note: This is a really excellent course by Kyle Shevlin. From an introduction to why state machines are useful in your work, to asynchronous management of a state machine, this course covers it at. Please watch it in Egghead first, then come back for the notes.
00:31 - Course Intro and Overview
Three parts, why state machines, second xstate lib, third advanced, like async, promsises, other state machines.
00:07 - Eliminate Boolean Explosion by Enumerating States
The lightbulb example.
An object that has several methods, and a state. It can have both "lit" and "broken" state (wrong). You can guard against this by adding an if
guard.
Instead, you can create a state variable to show the possible states of the lightbulb.
const STATES = {
lit: 'lit',
unlit: 'unlit',
broken: 'broken'
}
now, update the code to use this as the state
. And add a switch statement to toggle the lit state.
Now, we can only choose one possible state from the STATES
enumeration.
00:09 - Replace Enumerated States with a State Machine Let's replace all this with a state machine!
Creates individual objects:
const lit = {}
const unlit = {}
const broken = {}
const states = { lit, unlit, broken }
const initial = 'unlit'
// then create a config
const config = {
id: 'lightBulb',
initial,
states
}
00:48 - Replace Enumerated States with a State Machine
With state machines, we trigger transitions through events.
Let's add the break event to the lit
and unlit
. We do this via an on
property.
We capitalise the name of the event, and the value is the targeted state we want to transition to.
const lit = {
on: {
BREAK: 'broken',
TOGGLE: 'unlit'
}
}
const unlit = {
on: {
BREAK: 'broken',
TOGGLE: 'lit'
}
}
01:17 - Replace Enumerated States with a State Machine
We also added the TOGGLE
event "handler".
The broken
state has no event property, just
const broken = {
type: 'final'
}
So this is the final state it won't transition to another state after.
01:45 - Replace Enumerated States with a State Machine Now, can import the factory state function from the XState library:
const config = {
id: 'lightBulb',
initial,
states
}
const lightBulbMachine = Machine(config)
// now this has some useful getters and setters:
console.log(lightBulbMachine.initialState) // logs the starting state + lots of other info
console.log(lightBulbMachine.transition('unlit', 'TOGGLE')) //
.transition
is a pure function. It returns the next state object. it's a huge object
02:23 - Replace Enumerated States with a State Machine
Better just do
console.log(lightBulbMachine.transition('unlit', 'TOGGLE').value) // "lit"
02:44 - Replace Enumerated States with a State Machine if you pass an undefined state value for a state, it throws an error. but if you
console.log(lightBulbMachine.transition('foo', 'TOGGLE').value) // throws error
but an undefined event will return the current state
console.log(lightBulbMachine.transition('lit', 'FOO').value) // lit
03:02 - Replace Enumerated States with a State Machine or, you can do:
const config = {
id: 'lightBulb',
initial,
states,
strict: true
}
then, it'll throw an error.
00:03 - Use an Interpreter to Instantiate a Machine
interpreter
.transition(..)
is useful but tedious.
The interpreter
will allow the state machine to maintain its state and send events to it.
import { Machine, interpret } from 'xstate'
// the return value from an interpreter is called a service:
const service = interpret(lightBulbMachine)
// then start it:
.start()
// now, we can send events to it:
const nextState = service.send('TOGGLE')
00:48 - Use an Interpreter to Instantiate a Machine
But we don't have to save the nextState
, we can use the getter.
service.send('TOGGLE')
console.log(service.state)
Methods and listeners
There are many other methods and listeners to respond to changes in the state machine. 01:09 - Use an Interpreter to Instantiate a Machine
onTransition
The callback always receives the next state of the machine.
service.onTransition(state => {
console.log(state.value)
})
The callback will run every time we do a service.send(...)
.
can also check state.changed
:
service.onTransition(state => {
if (state.changed) {
console.log(state.value)
}
})
or
service.onTransition(state => {
if (state.matches('broken')) {
console.log('im brokin')
}
})
service.send('BREAK')
00:04 - Use XState Viz to Visually Develop and Test Your Machine
XState visualiser
A diagram of the state machine. It's interactive. You can click the events.
The state tab shows the current sate of the machine. The events tab lists the defined evets. Useful that you can pass extra data here:
01:18 - Use XState Viz to Visually Develop and Test Your Machine
{
"type": "BREAK",
"location": "living room"
}
Finally, we can save the state machine as a Gist.
00:05 - Add Actions to Transitions to Fire Side Effects
Defining actions on events
We want to send an action, when the lightbulb breaks in an unlit state. (still in the visualiser) The event value strings is just a shorthand:
...
"unlit" {
on: {
BREAK: {
target: 'broken',
// now we can add an actions property:
// it's an array of functions,
// each receiving the context of the state machine
// and the event object
actions: [(context, event) => {
// do stuff with these
}]
}
}
}
The second way to defining actions
01:17 - Add Actions to Transitions to Fire Side Effects As a second argument:
//...
{
//...
BREAK: {
target: 'broken',
actions: ['logBroken']
}
},
{
actions: {
logBroken: (context, event) => { console.log(`i am broke in ${event.location}`)}
}
}
Note, that above, now we pass extra info with the event (the location
).
00:00 - Trigger Actions When Entering and Exiting a XState State Still in the visualiser...
Calling actions when entering a state
Calling an actions
when entering a state (instead of the solution above, which is again a bit tedious).
{
//...
broken: {
entry: (context, event) => {
//...
}
// or just
entry: ['logBroken']
}
}
Now we don't need to define the target: 'broken'
as above.
Calling actions when leaving a state
01:37 - Trigger Actions When Entering and Exiting a XState State
states: {
lit: {
exit: () => {console.log('exiting')}
}
}
The order of entry
exit
events
02:18 - Trigger Actions When Entering and Exiting a XState State First the exit action, then the transition action, then the entry action.
00:12 - Replace Inline Functions with String Shorthands
using shorthands to define actions
We can pass these as an options object to the machine factory call.
00:19 - Replace Inline Functions with String Shorthands
On the second, options object, we can define, actions, guards, services, activities, delays.
const lightBulbMachine = Machine({
id: 'lightBulb',
initial: 'unlit',
states: {
lit: {
//..
},
unlit: {
//..
}
broken: {
// 2. can now instead use strings
entry: [logLocation, buyANewBulb],
// 3. use them as strings:
entry: ['logLocation', 'buyANewBulb']
}
}
},
// 1.
// add a second options object
{
actions: {
logLocation: (context, event) => {
// ...
},
buyANewBulb: (context, event) => {
// ...
}
}
}
)
00:03 - Use Internal Transitions in XState to Avoid State Exit and Re-Entry
Again, in the visualiser.
A contrived example, of an idle machine, with on: {DO_NOTHING: 'idle'}
It demonstrates that every time we call DO_NOTHING
, we do an exit
and entry
event.
How to keep the state, but without exit
and entry
?
By adding a .
in front of the event value:
//..
on: {
DO_NOTHING: '.idle'
}
This tells the state machine that we want to do an internal transition. We don't want to exit
it or entry
it.
The diagram line changes.
00:02 - Send Events to the Machine with the XState Send Action Creator
send action creator
An echo machine
see diagram...
//..
states: {
listening: {
on {
SPEAK: {},
ECHO: {
actions: () => {
console.log('echo echo')
}
}
}
}
}
We want every time SPEAK is triggered, the ECHO to be also triggered.
//..
states: {
listening: {
on {
SPEAK: {
// 1.
// add send
actions: send('ECHO'),
// or this format:
actions: send({ type: 'ECHO' })
},
ECHO: {
actions: () => {
console.log('echo echo')
}
}
}
}
}
every time we call SPEAK, it will call ECHO on the next tick of the machine.
00:08 - Track Infinite States with with XState Context
infinite states
these are considered to be context or extended states.
Example of multicolored lightbulb.
const multiColoredBulbMachine = Machine({
id: 'multiColoredBulb',
initial: 'unlit',
// 1. add context
context: {
color: '#fff'
},
states: {
lit: {
on: {
BREAK: 'broken',
TOGGLE: 'unlit'
}
}
unlit: {
//...
}
}
})
Updating contexts by assign actions
//..
states: {
lit: {
on: {
BREAK: 'broken',
TOGGLE: 'unlit',
CHANGE_COLOR: {
actions: assign({
color: '#f00'
}),
// or, you can do this signature
actions: assign({
color: (context, event) => event.color
}),
// or by passing it a function
// the returns an object to be merged into
// the context
actions: assign((context, event) => ({
color: event.color
})),
// 3. or defining it in the second options object
// see below a (3.)
actions: ['changeColor']
}
}
}
unlit: {
//...
}
},
// 3. the options object:
{
actions: {
changeColor: assign((context, event) => ({
color: event.color
})),
}
}
02:49 - Track Infinite States with with XState Context
The preferred way is to call assign with the object signature.
00:00 - How Action Order Affects Assigns to Context in a XState Machine
Actions order and context
see the diagram here
00:30 - How Action Order Affects Assigns to Context in a XState Machine
In his demo, he has a list (array) of actions
. Each function receives the context
and each one logs out the same context.count
value even if they all increment the count
.
This is because how the machine transition method works.
01:21 - How Action Order Affects Assigns to Context in a XState Machine
explians how Machine.transition
works. It's a pure function.
pseudo code to explain:
{
context: nextContext,
actions: [
...state.exit,
...actions,
...nextState.entry
].filter(action => {
if(assignAction) {
mergeIntoNextContext()
return false
}
return true
})
}
so it filters out all assign actions that may happen, and merge it into the next context. So all the assigns are batched together to give the next context.
Now, we need to update our code, to take into account the previous count:
//..
id: 'doubleCounter',
initial: 'idle',
context: {
count: 0,
// 1. add previous count
previousCount: undefined
},
states: {
idle: {
on: {
INC_COUNT_TWICE: {
actions: [
'setPreviousCount',
'incCount',
'incCount'
// ...
]
}
}
}
},
{
actions: {
incCount: assign({
count: context => context.count + 1
})
// 2.
setPreviousCount: assign({
previousCount: context => context.count
})
}
}
00:01 - Use Activities in XState to Run Ongoing Side Effects
An alarm clock machine
Activities
We want the alarming to be a continous state. Activities is an ongoing side effect that takes non zero amount of time.
const alarmClockMachine = Machine(
{
id: 'alarmClock',
intitial: 'idle',
states: {
idle: {
on: { ALARM: 'alarming' }
},
alarming: {
// 1. remove the actions
// actions: [],
// 2. add activities
activities: [(context, event) => {}],
// or string shorthand
activities: ['beeping'],
on: { STOP: 'idle' }
}
}
},
{
activities: {
beeping: (context, event) => {
const beep = () => {
console.log('beep')
}
beep()
// make sure return the cleanup function
const intervalID = setInterval(beep, 1000)
return () => clearInterval(intervalID)
}
}
}
)
00:01 - Conditionally Transition to States with Guards in XState
Adding a guard
You can use a cond
property. It can return true
or false
depending if we want to allow or not the transition:
//...
idle: {
on: {
SELECT_ITEM: {
target: 'vending',
cond: (context, event) => context.deposited >= 100
}
}
}
// or, as usual, use the second options object:
{
actions: {
addQuarter: assign({
deposited: context => context.deposited + 25
})
},
// 2. add here
guards: {
depositedEnough: (context, event) => context.deposited >= 100
}
}
00:02 - Simplify State Explosion in XState through Hierarchical States
Different states depending on each other. A door can be locked and closed but also ulocked and closed etc... But when opened, it should never be locked.
const door = Machine({
id: 'door',
initial: 'locked',
states: {
locked: {},
unlocked: {},
closed: {},
opened: {}
}
})
To simplify these scenarios, we can use hierarchical states.
const door = Machine({
id: 'door',
initial: 'locked',
states: {
locked: {
on: { UNLOCK: 'unlocked' }
},
unlocked: {
initial: 'closed',
states: {
closed: {
on: {
OPEN: 'opened'
}
},
opened: {
on: {
CLOSED: 'closed'
}
}
}
},
}
})
But how do we go from unlocked to locked state (should be closed)?
const door = Machine({
id: 'door',
initial: 'locked',
states: {
locked: {
// 2. add an id to it
// so that we can refer to it
// down the tree
id: 'locked',
on: { UNLOCK: 'unlocked' }
},
unlocked: {
initial: 'closed',
states: {
closed: {
on: {
// 1. naive, try lock:
// will not work, locked is one level up
LOCK: 'locked',
// instead like this:
LOCK: '#door.locked',
// or instead like this see, (2) above
LOCK: '#locked',
OPEN: 'opened'
}
},
opened: {
on: {
CLOSED: 'closed'
}
}
}
},
}
})
00:00 - Multiple Simultaneous States with Parallel States
A space heater machine
See the diagram...
The heater has this structure:
//...
states: {
poweredOff: {
//..
}
poweredOn: {
//..
states: {
lowHeat: {
//...
},
highHeat: {
//...
}
}
}
}
we want to add oscillation, but it doesn't fit into the above structure. It should happen in parallel with the heating states. It should not be affected by lowHeat
or highHeat
.
00:43 - Multiple Simultaneous States with Parallel States
Create parallel state
There's no initial
state:
//...
poweredOn: {
on: {
//...
},
type: parallel,
states: {
heated: {
initial: 'lowHeat',
states: {
//...
}
},
oscillation: {
initial: 'disabled',
states: {
// 02:03 - [Multiple Simultaneous States with Parallel States](https://egghead.io/lessons/xstate-multiple-simultaneous-states-with-parallel-states)
//...
}
}
}
}
History state nodes
//...
states: {
poweredOff: {
on: { TOGGLE_POWER: 'poweredOn' },
// 2. toggle the history state
// using the dot notation
on: { TOGGLE_POWER: 'poweredOn.hist' },
}
poweredOn: {
on: { TOGGLE_POWER: 'poweredOff' },
initial: 'low',
states: {
lowHeat: {
on: { TOGGLE_HEAT: 'high' }
},
highHeat: {
on: { TOGGLE_HEAT: 'low' }
}
// 1. add history
// it will add history for
// this part of the state
hist: {
type: 'history'
}
}
}
}
The history state will go to the initial state if the history is empty. 01:06 - Recall Previous States with XState History States Nodes This does a shallow history of the state. This is great for the toggle functionality, as it remembers what to toggle from/to.
History, but with parallel states
You can move the hist
node, to one of the parallel states.
hist: {
type: 'history',
// change the default to deep:
history: 'deep'
}
Now if we toggle the parent node (e.g. power off the heater) it will remember the child settings when toggled back on.
00:00 - Use XState Null Events and Transient Transitions to Immediately Transition States
Idle, trying, success states and retrying
Using the Null Event.
const ifAtFirtYouDontSucceed = Machine({
id: 'tryTryAgain',
initial: 'idle',
context: {
tries: 0
},
states: {
idle: {
on: {TRY: 'trying'}
},
trying: {
entry: ['incTries'],
// 1. add an empty string:
// it represents the Null event
// a Null event is immediately taken when
// we enter the state
// it's called a "transient transition"
on: {
'': [
{ target: 'success', cond: 'triedEnough' },
// 2. the second condition when the first isnt met
{ target: 'idle' }
]
}
},
success: {}
}
},{
actions: {
incTries: assign({
tries.ctx => ctx.tries + 1
})
},
// 3. add the condition here
guards: {
triedEnough: ctx => ctx.tries > 2
}
})
The graph now shows an event without a name. This is our Null Event.
00:03 - Delay XState Events and Transitions
A basic stoplight machine (red, green, yellow). The goal is to delay one event after the other.
It's easy, just need to use after
, for the event name, instead of on
.
{
states: {
green: {
after: {
TIMER: 'yellow',
// or, to wait 3 seconds:
3000: 'yellow'
}
}
}
}
Finally, we can abstract this out, with the second option passed to Machine.
{
delays: {
GREEN_TIMER: 3000,
YELLOW_TIMER: 1000,
RED_TIMER: 4000
}
}
The times can also be a result of a functions, that receives the context and the event.
{
actions:{
incRushHour: assign({
rushHourMultiplier: ctx => ctx.rushHourMultiplier + 1
})
}
delays: {
GREEN_TIMER: ctx => ctx.rushHourMultiplier * 3000,
YELLOW_TIMER: ctx => ctx.rushHourMultiplier * 1000,
RED_TIMER: ctx => ctx.rushHourMultiplier * 4000
}
}
03:11 - Delay XState Events and Transitions
Now, the timer goes slower...
00:05 - Invoking a Promise for Asynchronous State Transitions in XState
Fetch some data, then update the state.
Every Promise
can be represented as a state machine: idle, success, or failed.
We can invoke promises when we enter state.
const fecthSomeData = () => {
return fetch('https://example.com')
.then(
// process the data (res.json()), etc
)
}
// the Machine definition
{
states: {
idle: {
on: { FETCH: 'loading' }
},
loading: {
// 1. invoke the promise here
invoke: {
id: 'fecthSomeData',
src: fecthSomeData,
// promises respond to two events:
onDone: {
target: 'success',
actions: assign({
someData: (context, event) => event.data
})
},
onError: {
target: 'failure',
actions: assign({
error: (context, event) => event.data
})
}
}
}
}
}
00:06 - Invoke Callbacks to Send and Receive Events from a Parent XState Machine
We want to filter by events. When the right type of event sent, we want to echo the event.
We can do this by invoking a callback as a service.
const echoCallbackHandler = (context, event) =>
(callback, onEvent) => {
onEvent(e => {
if (e.type === 'HEAR') {
callback('ECHO')
}
})
}
// 01:16 - [Invoke Callbacks to Send and Receive Events from a Parent XState Machine](https://egghead.io/lessons/xstate-invoke-callbacks-to-send-and-receive-events-from-a-parent-xstate-machine)
//..
{
states: {
listening: {
invoke: {
id: 'echoCallback',
// src is the callback handler
src: echoCallbackHandler
}
}
on: {
SPEAK: {
// 02:01 - [Invoke Callbacks to Send and Receive Events from a Parent XState Machine](https://egghead.io/lessons/xstate-invoke-callbacks-to-send-and-receive-events-from-a-parent-xstate-machine)
actions: send('HEAR', {
to: 'echoCallback'
})
}
}
}
}
00:03 - Invoke Child XState Machines from a Parent Machine
We want to call another Machine and let it decide when we move to the done state...
You can use the invoke
event.
{
state: {
idle: {
on: { ACTIVATE: 'active'}
},
active: {
invoke: {
id: 'child',
src: childMachine
}
}
}
}
02:18 - Invoke Child XState Machines from a Parent Machine
can do:
active: {
invoke: 'child',
scr: childMachine,
// when the child Machine is finished:
onDone: 'done'
}