State machines and Actors in XState v5

Sandro Maglione

Sandro Maglione

Web 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

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

Did you know? You can export a variable and a type with the same name in typescript ☝️ You can then use it both as a type and value with a single import πŸͺ„ Suggested with zod and Effect schema πŸ‘‡

Export SubscribeResponse with the same name both as a value and a type
Import SubscribeResponse once and use it both as a type and value
6
Reply

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.

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.

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?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.