Introduction to State Machines Using XState

In notebook:
Article Notes
Created at:
2020-04-10
Updated:
2020-04-12
Tags:
JavaScript pattern

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