β€’

tech

State machines and Actors in XState v5

Learn how to implement a state machine using actors in XState v5, how to organize the code for context, input, events, and actions, and how to invoke and use actors in a state machine.


Sandro Maglione

Sandro Maglione

Software development

Learn how to create state machines and use actors in XState v5:

  • How to organize the code for a state machine with context, input, events, and actions
  • How to create a state machine using setup
  • How to initialize the context using input
  • How to invoke and use actors to execute async actions
Open source repository

Organize machine code in XState

For a complete machine we group all the files in a folder:

  • context.ts: context type and initial value
  • input.ts: input type and function to initialize the context from the input
  • events.ts: export union of all event types
  • actions.ts: functions to execute for each action
  • machine.ts: final machine (setup and createMachine)

All the files for a machine are grouped together in a folder: actions, context, events, input, and the final machineAll the files for a machine are grouped together in a folder: actions, context, events, input, and the final machine

The final machine implementation imports all the files. We define the machine inside setup:

machine.ts
import * as Context from "./context";
import * as Input from "./input";
import type * as Events from "./events";
import * as Actions from "./actions";

export const editorMachine = setup({
  types: {
    input: {} as Input.Input,
    context: {} as Context.Context,
    events: {} as Events.Events,
  },
  actors: {
    /// ...
  },
  actions: {
    /// ...
  },
}).createMachine({

For more details on how to create a machine and how setup works you can read Getting started with XState and Effect - Audio Player

Context: type and initial value

context.ts exports a Context interface and value:

context.ts
export interface Context { 
  readonly content: string;
  readonly code: readonly TokenState[];
  readonly timeline: readonly TimelineState[];
  readonly selectedFrameId: string;
  readonly bg: string | undefined;
  readonly themeName: string | undefined;
  readonly fg: string | undefined;
} 

export const Context: Context = { 
  code: [],
  content: "",
  selectedFrameId: "",
  timeline: [],
  bg: undefined,
  fg: undefined,
  themeName: undefined,
}; 

By exporting Context both as an interface and value we can reference both type and value in a single declaration

Input: initialize context

It's common to have some outside data required to initialize the machine context.

This is the purpose of the input value in XState:

  • interface Input defines the type of the required input
  • The Input function take the input as value and returns the new context

πŸ’‘ Important: The return type of Input is a Promise. This is where we need to use Actors (more details below πŸ‘‡)

input.ts
import type * as Context from "./context";

export interface Input {
  readonly source: string;
}

export const Input = (input: Input): Promise<Context.Context> => /// ...

Events

events.ts is a union of types for all possible events in the machine.

πŸ’‘ All events contain a readonly type, used by XState to distinguish between events (Discriminated Unions)

Note: The xstate.init event is automatically sent by the machine itself when started.

We are going to use this event to initialize the machine context from Input.

events.ts
import type * as Input from "./input";

// When an actor is started, it will automatically
// send a special event named `xstate.init` to itself
export interface AutoInit {
  readonly type: "xstate.init";
  readonly input: Input.Input;
}

export interface AddEvent {
  readonly type: "add-event";
  readonly frameId: string;
  readonly mutation: EventSend;
}

export interface SelectFrame {
  readonly type: "select-frame";
  readonly frameId: string;
}

export interface UnselectAll {
  readonly type: "unselect-all";
}

export type Events =
  | AutoInit
  | AddEvent
  | SelectFrame
  | UnselectAll;

Actions

actions.ts contains the functions to execute for each event.

Note: An assign function returns Partial<Context.Context> (sync updated context).

When an assign action returns a Promise instead we need to use Actors.

actions.ts
import type * as Context from "./context";
import type * as Events from "./events";
    
export const onAddEvent = (
  context: Context.Context,
  params: Events.AddEvent
): Promise<Partial<Context.Context>> => /// ...

export const onSelectFrame = (
  params: Events.SelectFrame
): Partial<Context.Context> => /// ...

export const onUnselectAll = (
  context: Context.Context
): Partial<Context.Context> => /// ...

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Implement a state machine in XState

We build the final state machine inside machine.ts:

  • Import and use context, input, events, and actions
  • Create the machine using setup
  • Create actors using fromPromise
machine.ts
import { assign, fromPromise, setup } from "xstate";
import * as Actions from "./actions";
import * as Context from "./context";
import type * as Events from "./events";
import * as Input from "./input";

export const editorMachine = setup({
  types: {
    input: {} as Input.Input,
    context: {} as Context.Context,
    events: {} as Events.Events,
  },
  actors: {
    /// Create actors using `fromPromise`
  },
  actions: {
    /// Execute `Actions`
  },
}).createMachine({
  id: "editor-machine",
  context: Context.Context, // πŸ‘ˆ Initial context
  invoke: {
    /// Init context from `Input`
  },
  initial: "Idle",
  states: {
    AddingLines: {
      invoke: {
        /// Async assign action
      },
    },
    Idle: {
      on: {
        // πŸ‘‡ Event without parameters
        "unselect-all": {
          target: "Idle",
          actions: {
            type: "onUnselectAll",
          },
        },

        // πŸ‘‡ Async assign event (actor)
        "add-event": {
          target: "AddingLines",
        },

        // πŸ‘‡ Event with parameters
        "select-frame": {
          target: "Idle",
          actions: {
            type: "onSelectFrame",
            params: ({ event }) => event,
          },
        },
      },
    },
  },
});

Use Actors to initialize context from Input

We use an actor to initialize the context from some input (Input) with an async function (Promise).

An actor is created using invoke:

}).createMachine({
  id: "editor-machine",
  context: Context.Context,
  invoke: { 
    src: "onInit",
    input: ({ event }) => {
      if (event.type === "xstate.init") {
        return event.input;
      }

      throw new Error("Unexpected event type");
    },
    onDone: {
      target: ".Idle",
      actions: assign(({ event }) => event.output),
    },
  }, 

Adding invoke at the root of the machine will start an actor as soon as the machine starts.

The actor will be active for the lifetime of its parent machine actor.

Inside src we define the specific actor to invoke:

}).createMachine({
  id: "editor-machine",
  context: Context.Context,
  invoke: { 
    src: "onInit", 
    input: ({ event }) => {
      if (event.type === "xstate.init") {
        return event.input;
      }

      throw new Error("Unexpected event type");
    },
    onDone: {
      target: ".Idle",
      actions: assign(({ event }) => event.output),
    },
  }, 

The actors are defined inside setup.

In this example onInit is an actor created using fromPromise that returns Context.Context and requires Input.Input as parameter.

The actor calls Input.Input to initialize the context:

export const editorMachine = setup({ 
  types: {
    input: {} as Input.Input,
    context: {} as Context.Context,
    events: {} as Events.Events,
  },
  actors: { 
    onInit: fromPromise<Context.Context, Input.Input>(({ input }) =>
      Input.Input(input) 
    ), 
    onAddEvent: fromPromise<
      Partial<Context.Context>,
      { params: Events.AddEvent; context: Context.Context }
    >(({ input: { context, params } }) => Actions.onAddEvent(context, params)),
  },

Communication between actors with events

The machine and its child actor communicate by passing events.

Inside invoke we define a input value. Inside it we listen for the xstate.init event. This event contains the Input.Input parameter required to execute the actor:

}).createMachine({
  id: "editor-machine",
  context: Context.Context,
  invoke: {
    input: ({ event }) => {
      if (event.type === "xstate.init") {
        return event.input;
      }

      throw new Error("Unexpected event type");
    },
    onDone: {
      target: ".Idle",
      actions: assign(({ event }) => event.output),
    },
    src: "onInit",
  },

We then also define onDone that will be executed when the Promise completes. Inside it we use assign to read the output of the actor and update the context:

}).createMachine({
  id: "editor-machine",
  context: Context.Context,
  invoke: {
    input: ({ event }) => {
      if (event.type === "xstate.init") {
        return event.input;
      }

      throw new Error("Unexpected event type");
    },
    onDone: {
      target: ".Idle",
      actions: assign(({ event }) => event.output),
    },
    src: "onInit",
  },

Invoking actors inside states

The same logic can be used to execute assign actions inside a state.

In this example the onAddEvent action updates the context with an async function:

export const onAddEvent = (
  context: Context.Context,
  params: Events.AddEvent
): Promise<Partial<Context.Context>> => /// ...

We therefore need to invoke an actor that executes the async logic.

We start by defining a new state that the machine enters after add-event:

initial: "Idle",
  states: {
    AddingLines: { 
      invoke: {
        src: "onAddEvent",
        input: ({ context, event }) => {
          if (event.type === "add-event") {
            return { context, params: event };
          }

          throw new Error("Unexpected event type");
        },
        onDone: {
          target: "Idle",
          actions: assign(({ event }) => event.output),
        },
      },
    },
    Idle: { 
      on: { 
        "add-event": { 
          target: "AddingLines", 
        }, 

The AddingLines state defines invoke to create a new actor:

initial: "Idle",
  states: {
    AddingLines: { 
      invoke: { 
        src: "onAddEvent", 
        input: ({ context, event }) => { 
          if (event.type === "add-event") {
            return { context, params: event };
          }

          throw new Error("Unexpected event type");
        },
        onDone: {
          target: "Idle",
          actions: assign(({ event }) => event.output),
        },
      }, 
    }, 
    Idle: {
      on: {
        "add-event": {
          target: "AddingLines",
        },

The actor is defined inside setup. It used fromPromise to execute the onAddEvent function:

export const editorMachine = setup({
  types: {
    input: {} as Input.Input,
    context: {} as Context.Context,
    events: {} as Events.Events,
  },
  actors: {
    onInit: fromPromise<Context.Context, Input.Input>(({ input }) =>
      Input.Input(input)
    ),
    onAddEvent: fromPromise<
      Partial<Context.Context>, 
      { params: Events.AddEvent; context: Context.Context } 
    >(({ input: { context, params } }) => Actions.onAddEvent(context, params)), 
  },

Finally, we use onDone to update the context and come back to the Idle state:

initial: "Idle",
  states: {
    AddingLines: { 
      invoke: { 
        src: "onAddEvent",
        input: ({ context, event }) => {
          if (event.type === "add-event") {
            return { context, params: event };
          }

          throw new Error("Unexpected event type");
        },
        onDone: { 
          target: "Idle", 
          actions: assign(({ event }) => event.output), 
        }, 
      }, 
    }, 
    Idle: {
      on: {
        "add-event": {
          target: "AddingLines",
        },

This is how you organize a state machine and use actors in XState v5.

You can use a similar logic to execute more complex actors and machines (using createMachine, fromTransition, fromObservable and more).

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below πŸ‘‡

Thanks for reading.

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.