Home

State Machines in JavaScript with XState

This comes from the State Machines course on Frontend Master. Please support the course.

Resources

  1. State Machines course
  2. Course repo
  3. Slides

The aim of this workshop is to understand state machines with no libraries before moving on.

Bottom Up Code

The example here has a button with an event listener added. This is generally how we work: we put all of our application logic inside of the event handlers themselves.

The issue with the event handler mentioned is that multiple clicks run, we could continually be refetching data.

Now the application lives inside the applicatin handlers, which is not what we want. This style of coding to "just get it done" is considered bottom up.

It makes it:

  • Difficult to test.
  • Difficult to understand.
  • Will contain bugs.
  • Difficult to enhance.
  • Features make it worse.

Why use state machines and statecharts?

  1. Visualized modelling
  2. Precise diagrams
  3. Automatic code generation
  4. Comprehensive test coverage
  5. Accomodation of late-breaking requirements changes

The benefit of the diagrams gives the logic in completeness. This is great for those who are seeing this without a technical background.

Graphs

This section speaks to graph theory.

The part on directed graphs speaks on the terms of "source", "transfer" and "sink" nodes.

Finite State Machines

A kind of directed graph consider a quintuple (five important parts).

There is an example of going through the lifecycle of a JavaScript Promise. It speaks to the transition states.

The parts of the state machine:

  1. Finite states
  2. Transitions
  3. Events (labelled on edges/transitions)
  4. Initial state (all FSMs start with this) - there is a dot to represent a "pseudo-transition" in the graphs.
  5. Final states (not to be covered too much)

An entry example

To model the states, the example uses a function for transition to model the Promise with a switch statement that switches on the state.

It has switches within switches in this example, and some odd representations, but the idea is that it always covered all possible states.

As for using an object instead of a switch:

const machine = { initial: 'idle', states: { idle: { on: { FETCH: 'pending' } }, pending: { on: { RESOLVE: 'resolved', REJECT: 'reject' } } resolved: {}, rejected: {} } } function transition(state,event) { return machine .states[state]? .on?.[event] || state }

Interpreting state machines:

// keep track of state let currentState = machine.initial; // receive events function send(event) { // Determine the next state const nextState = transition(currentState, event); // Update the current state currentState = nextState; } // Send some event send('CLICK');

XState

Simplifies the issues with adding/cleaning up listeners + far more.

npm i xstate

Using it in the file:

import { createMachine } from 'xstate'; const feedbackMachine = createMachine({ initial: 'question', states: { question: { // transitions: on: { CLICK_GOOD: 'thanks', CLICK_BAD: 'form', }, }, form: { // transitions on: { SUBMIT: { target: 'thanks', }, }, }, thanks: { // ... on: { CLOSE: 'closed', }, }, closed: { // Setting final node type: 'final', }, }, }); // Note, the following is long-hand const feedbackMachine = createMachine({ states: { on: { SUBMIT: { target: 'thanks', }, }, }, }); // ... for ... createMachine({ states: { on: { SUBMIT: 'thanks', }, }, }); // initial state const initialState = feedbackMachine.initialState; // An `event` is an object with a `type` const clickGoodEvent = { type: 'CLICK_GOOD', }; // An event object with payload const submitEvent = { type: 'SUBMIT', feedback: 'Very good, thank you', }; const nextState = feedbackMachine.transition( feedbackMachine.initialState, clickGoodEvent, );

Events are objects so we can pass in custom payloads.

Interpret function

Creates a service: a running instance of a machine.

import { createMachine, interpet } from 'xstate'; // omitted set up feedbackMachine const feedbackService = interpret(feedbackMachine); feedbackService.onTransitin(state => { console.log(state); }); feedbackService.start(); // when you're done for clanup feedbackService.stop();

Visualize

One of the benefits of XState is that you can visualize the machine!

XState Actions

Action docs

  1. Transition actions: Moving between states
  2. Entry actions: Entering into state
  3. Exit actions: Exiting state

The Action order will go exit, transition, then entry. We do not want to rely on action order too much.

These actions when added could look like this:

const enterActive = () => console.log('Enter') const transitionActive = () => console.log('Transition') const exitActive = () => console.log('Exit') const enterInctive = () => console.log('Enter inactive') const feedbackMachine = createMachine({ states: { entry: enterActive on: { CLICK: { target: 'thanks', action: transitionActive }, }, exit: exitActive }, });

You could pass multiple actions as an array. Remember: do not rely on order.

We can also pass the actions in the second argument to createMachine:

const feedbackMachine = createMachine( { states: { entry: ['enterActive', 'sendTelemetry'], on: { CLICK: { target: 'thanks', action: 'transitionActive', }, }, exit: 'exitActive', }, }, { actions: { enterActive: () => console.log('Enter'), sendTelemetry: () => console.log('sendTelemetry'), transitionActive: () => console.log('Transition'), exitActive: () => console.log('Exit'), enterInctive: () => console.log('Enter inactive'), }, }, );

Context + Assignment

import {createMachine, assign} from 'xstate' const feedbackMachine = createMachine( { initial: 'entry', context: { count: 0 } states: { // prefer this object syntax // to wholesale function syntax entry: assign({ count: (context, event) => { return context.count + 1 } }) on: { CLICK: { target: 'thanks', action: 'transitionActive', }, }, exit: 'exitActive', }, }, )

Transitions

Guarded Transition docs.

Conditional Predicates

Example here used for retries:

import {createMachine, assign} from 'xstate' const feedbackMachine = createMachine( { initial: 'entry', context: { count: 0 } states: { failure: { on: { RETRY: { target: 'loading', actions: assign({ retries: (context, event) => context.retries + 1 }) // HERE is the conditional cond: 'noExceededRetries' } } } }, }, { // guard for conditional guards: { noExceededRetries: (context, event) => { return context.retries < 5 } } } )

Transient Transitions

Transient docs

Happen on "null" events. Most useful with conditionals.

const gameMachine = Machine( { id: 'game', initial: 'playing', context: { points: 0, }, states: { playing: { on: { // Transient transition // Will transition to either 'win' or 'lose' immediately upon // (re)entering 'playing' state if the condition is met. '': [ { target: 'win', cond: 'didPlayerWin' }, { target: 'lose', cond: 'didPlayerLose' }, ], // Self-transition AWARD_POINTS: { actions: assign({ points: 100, }), }, }, }, win: { type: 'final' }, lose: { type: 'final' }, }, }, { guards: { didPlayerWin: (context, event) => { // check if player won return context.points > 99; }, didPlayerLose: (context, event) => { // check if player lost return context.points < 0; }, }, }, ); const gameService = interpret(gameMachine) .onTransition(state => console.log(state.value)) .start(); // Still in 'playing' state because no conditions of // transient transition were met // => 'playing' // When 'AWARD_POINTS' is sent, a self-transition to 'PLAYING' occurs. // The transient transition to 'win' is taken because the 'didPlayerWin' // condition is satisfied. gameService.send('AWARD_POINTS'); // => 'win'

Delayed Transitions

Delayed Events and Transitions

Transition states happen in zero time. They are never asynchronous. State machines are never async.

You can use delayed transitions with some trickery.

const lightDelayMachine = Machine( { id: 'lightDelay', initial: 'green', context: { trafficLevel: 'low', }, states: { green: { after: { // after 1 second, transition to yellow LIGHT_DELAY: 'yellow', }, }, yellow: { after: { YELLOW_LIGHT_DELAY: 'red', }, }, // ... }, }, { // String delays configured here delays: { LIGHT_DELAY: (context, event) => { return context.trafficLevel === 'low' ? 1000 : 3000; }, YELLOW_LIGHT_DELAY: 500, // static value }, }, );