DEV Community

Mikey Stengel
Mikey Stengel

Posted on • Updated on

State machine advent: Building hierarchy using the actor model and cross-machine communication (22/24)

One of the reasons why state machines and statecharts enable us to write great applications is the fact that they have been around for ages and have gone through decades of iterations until formalized in the SCXML specification which is strictly being followed by XState as well. Today, we will introduce another old and established mathematical model in computer science, the actor model.

An actor in XState is simply a machine that communicates with other machines. They can spawn other actors and send messages to one and another all while deterministically managing the application state. In XState, we communicate by sending events. Today, we want to learn how those abstract concepts translate into code.

Yesterday, we wrote a small player machine which can play a game of Rock, Paper, Scissors by invoking a tiny child machine that encapsulates just the behavior needed to play the game.

Today, we want to add a game machine that manages the two players, figures out who won and notifies the actor that came out on top. We'll use a lot of things in the toolbox of XState. To not be overwhelmed by the code, ensure you have a good grasp on the following concepts before diving in:

You can find the complete code in the Codesandbox here. Below, you can find all the relevant pieces and I'll explain the most important parts piece by piece.

Before writing actors, we used to think solely about the state architecture of the machine. Adding actors to the mix, we should also think about their hierarchy, how the different actors communicate with each other and which machines spawn which actors. When writing actors, we should also be aware of their responsibility. After all, they are just computational units that can talk to others. Generally speaking, the smaller the unit, the easier it can be reused and understood. Speaking about small is particularly referencing the number and complexity of events. They essentially represent the complete API surface area of an actor. On the contrary, you can design a deep or complex orthogonal state hierarchy, and the parent machine of an actor wouldn't know as it merely talks to its child with events. In other words, the finite and infinite-state (context) of actors are private and can only be shared with other actors using explicit events.

To give one example of how the actor architecture could vary, the game machine could be a spawned actor of the player machine and in turn invoke the second player machine (as an actor). As an introduction to actors, I decided that the game machine itself should be invoking and managing both player machines.

Actors live inside the context of a machine. Let's define their types:

import { Actor } from 'xstate';

export type PlayerActor = Actor<ActorContext, ActorEvent>;


interface GameStateContext {
  playerRefs: [] | [PlayerActor, PlayerActor];
}
Enter fullscreen mode Exit fullscreen mode

The ActorContext and ActorEvent are the types of the child machine. We either want the playerRefs to be an empty array (initial context) or to be an array with exactly two actors.

One can spawn actors inside actions. As a result, spawning an actor can happen at any point in time, dynamically. Meanwhile, when we invoked a state machine or promise as a service, we had to define a distinct state node. Therefore, more flexibility is an added benefit of using actors.

Enough talking, let's spawn some actors inside our game machine:

initializing: {
  on: {
    "": {
      target: "idle",
      actions: assign({
        playerRefs: () => [
          spawn(
            actorMachine.withContext({
              identity: "player1"
            }),
          ),
          spawn(
            actorMachine.withContext({
              identity: "player2"
            }),
          )
        ]
      })
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

We can spawn the two players by importing the spawn function from XState and calling it within the assign call. This sets the actor references to the game machine context. Speaking of which, just like a component can set the initial context of a machine, the machine itself can set the initial context of its actors by spawning them and calling withContext. Because both have the same type, we use this mechanism to assign each player an identity. This helps us to easily distinguish the actor of player one from the player2 sibling.

After we've spawned the actors, we want to communicate with them. By using the playerRefs variable inside the context, we can send each one an event.

Once our game machine receives an event to start playing the game, it should notify both players to start playing as well.

// inside the parent (gameMachine)
idle: {
  on: {
    PLAY_GAME: {
      target: "playing",
      actions: [
        send("PLAY", {
          to: context => context.playerRefs[0]
        }),
        send("PLAY", {
          to: context => context.playerRefs[1]
        })
      ]
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

Once the child machine has finished playing, the parent should know about it. To succeed with the actor model in XState, we need to think about every message as an event. If you like to think in "request" and "response", they too are merely events that conditionally result in actions being executed and/or a state transition to occur.

As a result, for the parent to be notified about anything the child actor does, it should define an event first.

// type of the gameMachine
export type FinishedPlayingEvent = {
  type: "FINISHED_PLAYING";
  action: ROCK_PAPER_SCISSORS;
  identity: "player1" | "player2";
};

type GameEvent =
  | { type: "PLAY_GAME" }
  | FinishedPlayingEvent
  | { type: "DETERMINE_WINNER" };
Enter fullscreen mode Exit fullscreen mode

If you are curious about the event implementation, you can find it further below or in the sandbox. For now, let's focus on how the child can notify its parent. It does so by... drumroll: sending the event.
To send an event from the child machine to the parent that invoked it, import sendParent from XState.

// inside the child machine
played: {
  entry: sendParent(
    context =>
      ({
        type: "FINISHED_PLAYING",
        action: context.playedAction,
        identity: context.identity
      })
  ),
  on: {
    PLAY: "playing"
  }
}
Enter fullscreen mode Exit fullscreen mode

With a very deep state structure, we can even replicate the actor context using distinct state nodes and conditional guards. It can be very powerful at times to encapsulate behavior in child actors and still have the possibility to put them in declarative state nodes for your UI or further assertions like we do to decide on a winner.

If you have ever played Rock, Paper, Scissors before, you know how difficult it can be to distinguish paper from scissors. 😁 Let's add a "referee" state node that receives the FINISHED_PLAYING event of the child machine, conditionally transitions the game state and will determine a winner once both actors have made their move.

Below, you can see a very detailed example of this in action.

interface GameStateSchema {
  states: {
    initializing: {};
    idle: {};
    playing: {
      states: {
        referee: {};
        player1: {
          states: {
            deciding: {};
            action: {
              states: {
                rock: {};
                paper: {};
                scissors: {};
              };
            };
          };
        };
        player2: {
          states: {
            deciding: {};
            action: {
              states: {
                rock: {};
                paper: {};
                scissors: {};
              };
            };
          };
        };
      };
    };
    draw: {};
    winner: {
      states: {
        player1: {};
        player2: {};
      };
    };
  };
}

// then inside the game machine definition
playing: {
  type: "parallel",
  states: {
    referee: {
      on: {
        FINISHED_PLAYING: [
          {
            target: "player1.action.rock",
            cond: (context, event) =>
              event.identity === "player1" && event.action === "ROCK"
          },
          {
            target: "player1.action.paper",
            cond: (context, event) =>
              event.identity === "player1" && event.action === "PAPER"
          },
          {
            target: "player1.action.scissors",
            cond: (context, event) =>
              event.identity === "player1" && event.action === "SCISSORS"
          },
          {
            target: "player2.action.rock",
            cond: (context, event) =>
              event.identity === "player2" && event.action === "ROCK"
          },
          {
            target: "player2.action.paper",
            cond: (context, event) =>
              event.identity === "player2" && event.action === "PAPER"
          },
          {
            target: "player2.action.scissors",
            cond: (context, event) =>
              event.identity === "player2" && event.action === "SCISSORS"
          }
        ],
        DETERMINE_WINNER: [
          {
            target: "#draw",
            cond: (context, event, stateGuard) => {
              if (!haveBothPlayersMadeTheirMove(stateGuard.state)) {
                return false;
              }
              const isGameDrawn = haveBothPlayersMadeTheSameMove(
                stateGuard.state
              );
              return isGameDrawn;
            }
          },
          {
            target: "#winner.player1",
            cond: (context, event, stateGuard) => {
              if (!haveBothPlayersMadeTheirMove(stateGuard.state)) {
                return false;
              }
              const player1Action =
                stateGuard.state.value["playing"]["player1"]["action"];
              const player2Action =
                stateGuard.state.value["playing"]["player2"]["action"];
              const didPlayer1Win = gameLogic[player1Action].beats(
                player2Action
              );
              return didPlayer1Win;
            }
          },
          {
            target: "#winner.player2",
            cond: (context, event, stateGuard) => {
              if (!haveBothPlayersMadeTheirMove(stateGuard.state)) {
                return false;
              }
              const player1Action =
                stateGuard.state.value["playing"]["player1"]["action"];
              const player2Action =
                stateGuard.state.value["playing"]["player2"]["action"];
              const didPlayer2Win = gameLogic[player2Action].beats(
                player1Action
              );
              return didPlayer2Win;
            }
          }
        ]
      }
    },
    player1: {
      initial: "deciding",
      states: {
        deciding: {},
        action: {
          entry: raise("DETERMINE_WINNER"),
          states: {
            rock: {},
            paper: {},
            scissors: {}
          }
        }
      }
    },
    player2: {
      initial: "deciding",
      states: {
        deciding: {},
        action: {
          entry: raise("DETERMINE_WINNER"),
          states: {
            rock: {},
            paper: {},
            scissors: {}
          }
        }
      }
    }
  }
},
draw: {
  id: "draw"
},
winner: {
  id: "winner",
  states: {
    player1: {
      entry: send("WON", {
        to: context => context.playerRefs[0]
      })
    },
    player2: {
      entry: send("WON", {
        to: context => context.playerRefs[1]
      })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Raise action

Note how the referee transitions conditionally to the distinct action state nodes of the players. The entry of the action state node, raises the DETERMINE_WINNER event, meaning it sends the event to itself (the invoked game machine). I find this very beautiful as it allows your machine to call events of itself while an actor could send the same event 😍
You can learn more about the raise action here.

State id and deep state transitions

When using a state id, we can reference it by prefixing #. This is useful for transitioning state from a deeply nested state to a state node higher in the hierarchy. It's a coincidence that the ids have the same name as the state node (winner/draw), you can call the id anything you want.

target: "#winner.player2"

After we reference the state id, we can chain nested state nodes (like player2) using the dot notation.

Handling the WON event in the player/child machine

Earlier I spoke about the minimal API surface area of actors. One of the powers of this mechanism is the fact that they just receive events while not knowing anything about the internal structure, conditional logic, and state complexity of the actor friends they talk to. Yet, they can perfectly react to events that are important to them.

As established before, the player actor wants to know if they won the encounter. It could react to it by counting how many times this particular actor has won the game, all without knowing about the complexity of the parent, gameMachine.

// inside the child machine
WON: {
  actions: assign({
    winCount: context => context.winCount + 1
  })
}
Enter fullscreen mode Exit fullscreen mode

So you don't have to scroll all the way up, here is the link to the sandbox again https://codesandbox.io/s/gracious-pare-qce8n

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.

Top comments (0)