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
π‘ Project update π‘ Remove, add, update lines with animation πͺ Advanced logic β @EffectTS_ services with new Content.Tag β XState @statelyai actors (`invoke`) Next level stuff π
Organize machine code in XState
For a complete machine we group all the files in a folder:
context.ts
: context type and initial valueinput.ts
: input type and function to initialize the context from the inputevents.ts
: export union of all event typesactions.ts
: functions to execute for each actionmachine.ts
: final machine (setup
andcreateMachine
)
All 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
:
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:
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 declarationDid 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 π
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 aPromise
. This is where we need to use Actors (more details below π)
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
.
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 returnsPartial<Context.Context>
(sync updated context).When an
assign
action returns aPromise
instead we need to use Actors.
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
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.