Actors allow to isolate the state and logic of each part of the app, to then reuse and combine them to build complex behaviors.
An example is an actor to manage the state of a form field. A common approach in XState without actors would be to add each field to context
and multiple events to update their state:
const machine = createMachine({
types: {
context: {} as {
// 👇 Add each field to context
firstName: string;
lastName: string;
},
events: {} as
// 👇 Add multiple events to update each field state
| { type: "first-name.update"; value: string }
| { type: "last-name.update"; value?: string },
},
context: { firstName: "", lastName: "" },
initial: "Editing",
states: {
Editing: {
on: {
// 👇 Repeat the same `assign` action for each field
"first-name.update": {
actions: assign(({ event }) => ({ firstName: event.value })),
},
"last-name.update": {
actions: assign(({ event }) => ({ lastName: event.value })),
},
},
},
},
});
This approach is not very scalable and verbose. We are repeating the same code for each field (context
, events
, assign
).
With actors instead we can isolate the state and logic of a field, and compose multiple of them to handle the form state.
Text field actor
XState offers multiple ways to create actors. In this example, since we only need a way to store and update state, we don't need a complete state machine.
We use fromTransition
instead.
If you want to learn more about each type of actor, check out XState: Complete Getting Started Guide.
fromTransition
works like a reducer function, updating the state based on the event type.
We start by defining the context of the actor (a single value
property):
interface Context {
value: string;
}
Then we define the events that the actor can handle:
type Event =
| { type: "update"; value: string }
| { type: "reset"; value?: string };
We can now define the actor using fromTransition
with the Context
and Event
types.
The second argument of
fromTransition
allows to define the initial context.In our case, we create a function that accepts an optional
input
to set the initialvalue
.
interface Context {
value: string;
}
type Event =
| { type: "update"; value: string }
| { type: "reset"; value?: string };
export const textFieldActor = fromTransition(
// 👇 Typed context and events
(_: Context, event: Event): Context => // ...
({ input }: { input?: { initialValue?: string } }) => ({
// 👇 Optional initial value (or default to empty string)
value: input?.initialValue ?? "",
})
);
Match each event
The Match
module of effect
brings pattern matching to TypeScript.
We use it to match each event and update the state based on the event type:
export const textFieldActor = fromTransition(
(_: Context, event: Event): Context =>
Match.value(event).pipe(
Match.when({ type: "update" }, (event) => ({ value: event.value })),
Match.when({ type: "reset" }, (event) => ({ value: event.value ?? "" })),
// 👇 Exhaustive pattern matching for all events
Match.exhaustive
),
({ input }: { input?: { initialValue?: string } }) => ({
value: input?.initialValue ?? "",
})
);
With this the actor is completed. We use the same pattern to create a numberFieldActor
and a optionalNumberFieldActor
:
export const numberFieldActor = fromTransition(
(_: Context, event: Event): Context =>
Match.value(event).pipe(
Match.when({ type: "update" }, (event) => ({
// 👇 `* 10` to handle decimal numbers as integers
value: Number.isNaN(event.value) ? 0 : event.value * 10,
})),
Match.when({ type: "reset" }, (event) => ({
value: event.value ?? 0,
})),
Match.exhaustive
),
({ input }: { input?: { initialValue?: number } }) => ({
value: input?.initialValue !== undefined ? input.initialValue * 10 : 0,
})
);
export const optionalNumberFieldActor = fromTransition(
(_: Context, event: Event): Context =>
Match.value(event).pipe(
Match.when({ type: "update" }, (event) => ({
value:
Number.isNaN(event.value) || event.value === 0
? undefined
: event.value * 10,
})),
Match.when({ type: "reset" }, (event) => ({
value: event.value ?? 0,
})),
Match.exhaustive
),
({ input }: { input?: { initialValue?: number } }) => ({
value:
input?.initialValue !== undefined ? input.initialValue * 10 : undefined,
})
);
Combining actors using spawn
Instead of defining each field as a primitive value, we use ActorRefFrom
to type them as actors:
const machine = createMachine({
types: {
context: {} as {
firstName: ActorRefFrom<typeof textFieldActor>;
lastName: ActorRefFrom<typeof textFieldActor>;
},
},
// ...
});
When initializing the machine we can use spawn
inside context
to create each actor:
const machine = createMachine({
types: {
context: {} as {
firstName: ActorRefFrom<typeof textFieldActor>;
lastName: ActorRefFrom<typeof textFieldActor>;
},
},
context: ({ spawn }) => ({
firstName: spawn(textFieldActor),
lastName: spawn(textFieldActor),
}),
// ...
});
With this the logic to update the state of each field is isolated and reusable.
We don't need to define other events or
assign
actions 🪄
const machine = createMachine({
types: {
context: {} as {
firstName: ActorRefFrom<typeof textFieldActor>;
lastName: ActorRefFrom<typeof textFieldActor>;
},
},
context: ({ spawn }) => ({
firstName: spawn(textFieldActor),
lastName: spawn(textFieldActor),
}),
initial: "Editing",
states: {
Editing: {},
},
});
When we want to extract the state of each field we can access the current snapshot from the actor:
const machine = createMachine({
types: {
context: {} as {
firstName: ActorRefFrom<typeof textFieldActor>;
lastName: ActorRefFrom<typeof textFieldActor>;
},
},
context: ({ spawn }) => ({
firstName: spawn(textFieldActor),
lastName: spawn(textFieldActor),
}),
initial: "Editing",
states: {
Editing: {
on: {
print: {
actions: ({ context }) => {
console.log(context.firstName.getSnapshot().context.value);
console.log(context.lastName.getSnapshot().context.value);
},
},
},
},
},
});
It's easy to extend the logic of each actor by updating a single actor. The changes will be reflected everywhere in the app (with a full type-safe API).