DEV Community

Mikey Stengel
Mikey Stengel

Posted on

State machine advent: Invoking a reusable state machine (21/24)

Two days ago, we learned about services for the very first time. In particular, we wrote our first service by invoking a promise. Today, we want to look at one of my favorite things to do in XState, invoking a machine as a service. When modeling state machines and statecharts, we should always strive to keep them as small as possible. We should write multiple small machines and wire them together using cross-machine communication and the actor model. The concept of invoking a machine as a service is quite similar to the promise. Just like we had to wait for the invoked promise to be resolved or rejected, a machine can invoke a child machine and be notified once the child machine has reached its final state via the onDone handler.

In XState, a machine can mark the last state - from which it doesn't define any more state transitions; therefore, it can't have events - with type: final.

To try this in action, we want to define some machines that allow us to play rock paper scissors. Where I'm from, most people scream "rock paper scissors" out loud before deciding on the action they take. 😄 Let's ensure our machine does the same thing by logging "rock", "paper", "scissor" with a small delay. Despite not focus of today's lecture, I wanted to sneak delayed transitions into the post because the ability to set a delay from one state to another with each state node performing a different action is really powerful and one of the reasons why XState resonates so well with animations.

Child machine

Let's get to building by defining some types. 🛠️

enum ROCK_PAPER_SCISSORS {
  'ROCK' = 'ROCK',
  'PAPER' = 'PAPER',
  'SCISSORS' = 'SCISSORS',
}

interface RockPaperScissorsStateSchema {
  states: {
    screamRock: {};
    screamPaper: {};
    screamScissors: {};
    randomizingAction: {};
    played: {};
  };
}

interface RockPaperScissorsContext {
  action: ROCK_PAPER_SCISSORS | 'NONE';
}

Let's implement the machine. Notice how we don't need any events in the child machine (third type argument is any when calling the Machine factory function). I'll explain why we don't define events (other than the null event) in a minute. For now, appreciate the fact that we are about to define our very first internal machine that will be invoked by another machine instead of a component 🤩

import { Machine, assign, actions } from 'xstate';
const { log } = actions;

const rockPaperScissorsMachine = Machine<RockPaperScissorsContext, RockPaperScissorsStateSchema, any>({
  id: 'rockPaperScissors',
  initial: 'screamRock',
  context: {
    action: 'NONE',
  },
  states: {
    screamRock: {
      entry: log((context, event) => "ROCK...", 'RockPaperScissors'),
      after: {
        // 1/10th later transition to scream "PAPER"
        100: 'screamPaper',
      },
    },
    screamPaper: {
      entry: log((context, event) => "PAPER...", 'RockPaperScissors'),
      after: {
        // 1/10th second later transition to scream "SCISSORS"
        100: 'screamScissors',
      },
    },
    screamScissors: {
      entry: log((context, event) => "SCISSORS...", 'RockPaperScissors'),
      after: {
      // 1/10th second later transition to randomizingAction
        100: 'randomizingAction',
      },
    },
    randomizingAction: {
      on: {
        '': {
          actions: assign({
            // click on the linked codesandbox at the very end
            // if you are curious about the randomEnum function 
            action: () => randomEnum(ROCK_PAPER_SCISSORS),
          }),
          target: 'played',
        },
      },
    },
    played: {
      type: 'final',
      data: {
        performedAction: (context, event) => context.action,  
      }
    },
  },
});

See how the machine can additionally define some extended state via data that the parent machine can read once the child reaches the final state. We can refer to it as done data.

Before moving on to the parent machine, let me tell you why we have defined a randomizingAction state with a null event. Right before the machine transitions to the next state (played), a randomized ROCK_PAPER_SCISSORS value is assigned to the action property of the machine's context. Alternatively, just like we perform a log action when entering the screaming state nodes, the action of changing the context could've also been performed as an entry action of the played state.
On the contrary, once we have entered the played state, we'd usually expect the action to be already set to ROCK | PAPER | SCISSORS. To prevent ambiguity, we want to set the value before entering the state node; hence, we added a very explicit state node randomizingAction. Don't be frugal when it comes to defining state nodes, they can add a lot of clarity to the code we write.


Parent machine

Our rock paper scissors machine should be invoked by a user. Let's represent the user with an ActorMachine.

interface ActorStateSchema {
  states: {
    idle: {};
    playing: {};
    played: {};
  };
}

interface ActorContext {
  playedAction?: ROCK_PAPER_SCISSORS;
}

type ActorEvent = { type: 'PLAY' };

As you can see, there are quite some things we had to repeat like playing, played and the action value inside the machine's context. Former state is essential to invoke the child machine and once the machine has reached the final state, the parent machine will also transition to a state indicating that a game has been played. As established before, there is no need to define state nodes sparingly and we get some nice benefits from having defined a parent machine. In particular, we managed to encapsulate the few states and actions needed to play the game into a distinct rock paper scissors machine. By invoking the machine, it doesn't need to be aware of the idle state and PLAY event of the parent machine. As a benefit, the machine has a single responsibility and because of its minimal API surface, it can be easily reused.

const actorMachine = Machine<ActorContext, ActorStateSchema, ActorEvent>({
  id: 'player',
  initial: 'idle',
  context: {
    playedAction: undefined,
  },
  states: {
   idle: {
     on: {
       'PLAY': 'playing',
     },
   },
   playing: {
      invoke: {
        id: 'playRockPaperScissor',
        src: rockPaperScissorsMachine,
        onDone: {
          target: 'played',
          actions: assign({ playedAction: (context, event) => event.data.performedAction }),
        }
      }
    },
    played: {
        on: {
          /** Let's play again :) */
          PLAY: "playing"
        }
    },
  },
});

Looking at the parent machine, the most important part is clearly when the rockPaperScissorsMachine gets invoked. Once the event handler is called to indicate that the child machine has finished its execution, we assign the done data to the parent context. Just one of multiple ways to handle cross-machine communication. For learning purposes, I named the extended state differently every time so you can clearly tell the difference. You could also call them by the same name e.g action which might make the code easier to look at.

  • action: property in the context of the child machine
  • performedAction: property of the done data inside the final state of the child machine. Gets assigned the action value
  • playedAction: property in the context of the parent machine. Gets assigned the performAction value

You can see everything working together smoothly in this codesandbox.

About this series

Throughout the first 24 days of December, I'll publish a small blog post each day teaching you about the ins and outs of state machines and statecharts.

The first couple of days will be spent on the fundamentals before we'll progress to more advanced concepts.

Discussion (0)