At Stately, the Actor Model is one of our favorite programming paradigms, and we think it’s for good reason! The actor model allows developers to build reliable message-based systems by using actors to communicate. This works extremely well with state machines and statecharts, which can also be modeled as actors and can communicate much in the same ways. Read on to learn what the actor model is, the problems it seeks to solve, and how you can use it in your projects to communicate reliably across different entities.
What is the actor model?
The actor model has been around for quite a while, dating back to the 1970’s. That it’s used with frameworks like Akka and built natively into languages like Erlang are testaments to its utility. When researching the actor model, it’s very common to see the phrase “everything is an actor”, as it is a core tenet in the actor model philosophy. This simply means that in a given system, the actor is the core unit of execution. Every action that occurs in the system is driven by an actor. An actor can communicate with other actors with the use of messaging, and they can also interact with external systems. Specifically, an actor can perform the following basic tasks:
- Actors can manage their own internal state
- Actors can spawn other actors
- Actors can send messages to other actors
It may sound simple, but this programming model allows for the development of highly scalable and concurrent systems. There are constraints though, the most important of which is that an actor cannot modify the internal state of another actor directly. This can be done implicitly with messaging (i.e an actor updating its state in response to a message) but never directly.
When should I use it?
The actor model is extremely useful for splitting up work that can be processed in parallel. It’s excellent for “fan-out/fan-in” scenarios where several functions need to be run at once and have their results combined before final processing. It also works well with building pub/sub systems, where several actors can be “workers”, waiting for messages from the “publisher”. Our last example, but certainly not the least, is in the case of systems that need to manage several similar entities, like a multiplayer game where every player is represented as an actor. A good rule of thumb is to at least consider the actor model whenever distribution and concurrency are core requirements.
When not to use it
As with any pattern, it’s just as important to understand its weaknesses as it is to understand its strengths. Typically you may not want to use the actor pattern when order really matters. Order is usually not promised in the actor pattern, and if one of the actors fails, you’ll have to deal with the concern of rolling back events. The actor model is often unnecessary when dealing with synchronous problems as well and can add unnecessary overhead. Additionally, error handling can be tricky with actors. Erlang popularized the “let it crash” philosophy, but given the problem, it may not always be the most reasonable answer.
How does XState work with the actor model?
With XState, we expose the ability to create instances of machines as actors! Looking at the example below, we can see that after defining our machine and its attributes, we only need to use interpret()
to instantiate an actor (called toggleActor
) and send it messages.
import { createMachine, interpret } from 'xstate';
// State machine definition
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } },
},
});
// Machine instance with internal state
const toggleActor = interpret(toggleMachine);
toggleActor.subscribe((state) => {
console.log(state.value);
});
toggleActor.start();
// => logs 'inactive'
toggleActor.send({ type: 'TOGGLE' });
// => logs 'active'
toggleActor.send({ type: 'TOGGLE' });
// => logs 'inactive'
This actor has its own state and context, and it can be updated whenever it receives events. Of course, in order for the internal state of an actor to be updated, the event must be a legal transition defined with the machine.
Spawning Actors
XState can also be used to spawn other actors and communicate with each other:
import { createMachine, spawn } from 'xstate';
import { todoMachine } from './todoMachine';
const todosMachine = createMachine({
// ...
on: {
'NEW_TODO.ADD': {
actions: assign({
todos: ({ context, event }) => [
...context.todos,
{
todo: event.todo,
// add a new todoMachine actor with a unique name
ref: spawn(todoMachine, `todo-${event.id}`),
},
],
}),
},
// ...
},
});
With the use of spawn()
and assign()
, we create a new actor instance when provided the machine logic and a unique identifier.
By their nature, actions are "fire and forget" effects, meaning they are executed with no expectation of receiving events back to the actor. This makes sense for creating a new actor, but we may still want the parent actor to have a reference to its child, so we save that in its context using assign()
. spawn()
is the function called that actually creates the new actor. The parent can access this state easily by calling getSnapshot()
on the reference to the child.
For more detailed examples around working with actors in XState, like callback or promised-based actor spawning, sending updates, and communicating between actors, check out our docs on actors.