Actor Model
Spawn independent child machines from parent machines — isolated state, context, and lifecycle.
Actor Model
The Actor Model lets you spawn independent child state machines from a parent machine. Each actor runs in isolation with its own state, context, and lifecycle. This is essential for modeling concurrent workflows, delegation patterns, and task distribution.
🎭 What is the Actor Model?
flowchart TB
P["👑 parent machine<br/><small>orchestrates</small>"]
P -- "spawn_worker" --> A["🧵 worker #1<br/><small>own state · own context</small>"]
P -- "spawn_worker" --> B["🧵 worker #2<br/><small>own state · own context</small>"]
P -- "spawn_worker" --> C["🧵 worker #3<br/><small>own state · own context</small>"]
A -. "send to parent" .-> P
B -. "send to parent" .-> P
C -. "send to parent" .-> P
In the actor model, a running state machine (the parent) can create one or more child state machines (the actors). Each actor:
- Has its own state — independent of the parent
- Has its own context — isolated data that the parent cannot directly mutate
- Has its own lifecycle — starts, runs, and stops independently
- Receives a reference to the parent via
child.parent
This pattern is ideal for scenarios where a parent orchestrates multiple independent units of work, such as a task manager dispatching workers or an order system processing multiple items.
🐣 Spawning Actors with the spawn_ Prefix
To spawn an actor, define an action whose name starts with spawn_. The interpreter recognizes this prefix and treats it as a special built-in action. The suffix after spawn_ becomes the actor key, which must match a key in your MachineLogic.services dictionary.
The service must be either:
- A
MachineNodeinstance (a pre-built child machine), or - A factory function that returns a
MachineNode
Basic Example: Spawning a Child Machine
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
# 1. Define the child machine configuration
child_config = {
"id": "worker",
"initial": "idle",
"states": {
"idle": {"on": {"DO_WORK": "working"}},
"working": {"on": {"FINISH": "done"}},
"done": {"type": "final"}
}
}
# 2. Build the child machine
child_machine = create_machine(child_config)
# 3. Define the parent machine configuration
parent_config = {
"id": "manager",
"initial": "ready",
"states": {
"ready": {
"on": {
"HIRE": {"actions": "spawn_worker"}
}
}
}
}
# 4. Register the child machine as a service
parent_logic = MachineLogic(
services={"worker": child_machine}
)
# 5. Create and run the parent
parent_machine = create_machine(parent_config, logic=parent_logic)
interp = SyncInterpreter(parent_machine).start()
# 6. Spawn the actor by sending the event
interp.send("HIRE") # Spawns the 'worker' actor
interp.stop()
Note: The action name
spawn_workermaps to the service keyworker. The interpreter strips thespawn_prefix to look up the service.
🏭 Spawning with a Factory Function
Instead of providing a pre-built MachineNode, you can provide a factory function. This is useful when the child machine’s configuration depends on the parent’s context or the triggering event.
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
def create_worker(interpreter, context, event):
"""Factory that creates a child machine based on the event payload."""
worker_type = event.payload.get("type", "default")
worker_config = {
"id": f"worker-{worker_type}",
"initial": "processing",
"context": {"task_type": worker_type},
"states": {
"processing": {"on": {"COMPLETE": "done"}},
"done": {"type": "final"}
}
}
return create_machine(worker_config)
parent_config = {
"id": "dispatcher",
"initial": "listening",
"states": {
"listening": {
"on": {
"DISPATCH": {"actions": "spawn_taskRunner"}
}
}
}
}
parent_logic = MachineLogic(
services={"taskRunner": create_worker}
)
machine = create_machine(parent_config, logic=parent_logic)
interp = SyncInterpreter(machine).start()
interp.send("DISPATCH", type="email") # Creates worker-email
interp.send("DISPATCH", type="sms") # Creates worker-sms
interp.stop()
⏸️ Blocking Actors with spawn_blocking_
The SyncInterpreter supports blocking actors using the spawn_blocking_ prefix. A blocking actor starts immediately and the parent interpreter waits for it to reach a final state before continuing.
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
# Child machine that processes and reaches a final state
child_config = {
"id": "validator",
"initial": "validating",
"states": {
"validating": {
"on": {"": {"target": "valid"}}
},
"valid": {"type": "final"}
}
}
child_machine = create_machine(child_config)
parent_config = {
"id": "form",
"initial": "editing",
"states": {
"editing": {
"on": {
"VALIDATE": {"actions": "spawn_blocking_validator"}
}
}
}
}
parent_logic = MachineLogic(
services={"validator": child_machine}
)
machine = create_machine(parent_config, logic=parent_logic)
interp = SyncInterpreter(machine).start()
# This blocks until the validator reaches its final state
interp.send("VALIDATE")
interp.stop()
Tip: Use
spawn_blocking_when you need the child to complete before the parent processes the next event. Usespawn_(non-blocking) when the child should run concurrently in a background thread.
As of 0.8.0, both the async Interpreter and SyncInterpreter honour spawn_blocking_ the same way: the child actor runs to a terminal status ("done" or "error") before the parent’s next action executes. (Previously the async engine silently ran it non-blocking.)
A machine-level spawnBlockingTimeout config key (milliseconds) bounds how long the parent waits:
{
"id": "form",
"spawnBlockingTimeout": 5000,
"initial": "editing",
"states": { "editing": {} }
}
When unset, a 30-second default applies (DEFAULT_SPAWN_BLOCKING_TIMEOUT_MS). The wait is never unbounded: an unbounded wait inside a transition would wedge the parent forever if the child never reaches a final state, while the machine still reported "running". If the timeout lapses, the parent logs a warning and continues — it does not raise.
Passing input to an invoked child: invoke.input
invoke.input may be a static dict, or a callable resolved fresh per spawn via InvokeDefinition.resolve_input(context, event):
"invoke": {
"src": "childActor",
"id": "kid",
# static form
"input": {"greeting": "hi"},
}
"invoke": {
"src": "childActor",
"id": "kid",
# callable, two-positional form
"input": lambda ctx, evt: {"greeting": ctx["name"]},
}
"invoke": {
"src": "childActor",
"id": "kid",
# callable, single-arg XState form: fn({"context": ..., "event": ...})
"input": lambda args: {"greeting": args["context"]["name"]},
}
The resolved value is deep-copied and passed to the child as its creation input. The idiomatic way to parameterise a child is a context factory — context: lambda args: {"snapshot": args["input"]["snapshot"]} — which shapes the child’s context from the input exactly as in XState. A child with a plain dict context receives the input only under context["input"]; its declared keys are not overwritten. (Spreading input into declared keys would let any caller of Interpreter(machine, input=...) override the machine’s own defaults, so the library deliberately does not.)
If the resolver callable itself raises, the invocation routes to onError rather than propagating.
A completed parent now stops its children too — see Interpreters — Lifecycle: completion and teardown.
🧰 Built-in Actor Actions (v0.6.0)
Alongside the spawn_ naming convention above, XState v5’s built-in action
creators are supported. These are declared as objects in the config, so they
work from plain JSON with no Python naming convention required.
spawnChild and systemId
spawnChild starts a child machine. Registering it under a systemId gives it
a stable, machine-wide name that any actor in the system can address:
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
worker = {
"id": "worker",
"initial": "idle",
"context": {"jobs": 0},
"states": {"idle": {"on": {"JOB": {"target": "idle", "actions": ["count"]}}}},
}
worker_logic = MachineLogic(actions={
"count": lambda i, ctx, e, a: ctx.__setitem__("jobs", ctx["jobs"] + 1),
})
parent = {
"id": "super",
"initial": "up",
"context": {},
"states": {
"up": {
"entry": [{"type": "spawnChild",
"params": {"src": "worker", "id": "w1",
"systemId": "pool"}}],
"on": {"DISPATCH": {"actions": [
{"type": "sendTo",
"params": {"to": "pool", "event": {"type": "JOB"}}}
]}},
}
},
}
logic = MachineLogic(services={
"worker": lambda i, ctx, e: create_machine(worker, logic=worker_logic),
})
sup = SyncInterpreter(create_machine(parent, logic=logic)).start()
print(list(sup.system.get_all())) # ['pool']
sup.send("DISPATCH")
sup.send("DISPATCH")
print(sup.system.get("pool").context["jobs"]) # 2
sup.stop()
The system registry is reachable from any interpreter in the tree:
| Call | Returns |
|---|---|
interp.system.get("pool") |
The actor registered under that systemId, or None |
interp.system.get_all() |
A mapping of every registered systemId |
systemId registrations survive a snapshot round-trip, so sendTo("pool", ...)
still resolves after from_snapshot().
Registering a second live actor under a systemId that is already taken raises
ActorSpawningError — systemIds must be unique among currently-running
actors.
sendTo, sendParent, stopChild
| Action | Purpose |
|---|---|
sendTo |
Send an event to another actor, by id or systemId |
sendParent |
Send an event to the machine that spawned this one |
stopChild |
Stop a spawned child by id |
forwardTo |
Forward the current event to another actor |
escalate |
Raise an error to the parent |
# child -> parent
{"type": "sendParent", "params": {"event": {"type": "WORK_DONE"}}}
# parent -> named child, after a delay
{"type": "sendTo", "params": {"to": "pool",
"event": {"type": "JOB"},
"delay": 500, "id": "job-1"}}
# cancel that delayed send before it fires
{"type": "cancel", "params": {"sendId": "job-1"}}
Note: A child invoked with
invokefires the parent’sonDoneonly when it reaches a top-level final state, andonErrorif it ends in an error. Thedone.invoke.<id>payload is the child’s declaredoutput(machine-leveloutputwins over the final state’s), never its rawcontext(0.8.1, #109). Stopping a child early does not fire either. As of 0.8.0, invoked child completion is detected immediately via a terminal-listener callback, not by polling — the old_ACTOR_POLL_INTERVALno longer exists.
Unresolvable
to(0.8.1, #133). AsendTowhosetonames no live actor does not raise. The send is dropped,on_event_dropped(reason="unresolved_target")fires, and the step is marked:last_transition_okisFalseandreceipt.error/last_errorcarry anActorSpawningErrornaming the target.escalatefrom an invoked child reaches the parent’sonError(#130).
Threading (sync engine, 0.8.1). A non-blocking
spawnChildonSyncInterpreterstarts the child on the spawning thread — its entry actions and any grandchildren exist by the time the spawn action returns — and hands it to a background pump thread afterwards. Only the child’s later ticks run on that thread.
💬 Actor Communication
sequenceDiagram
participant P as parent
participant W as worker (actor)
P->>W: spawn_worker
P->>W: send_to("worker", "START")
W->>W: runs its own machine
W-->>P: send_parent("DONE", result)
P->>P: on DONE → next state
Actors and parents communicate through events. After spawning, the parent can interact with the child through actions that reference the child via the interpreter’s actor management:
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
child_config = {
"id": "processor",
"initial": "waiting",
"context": {"items_processed": 0},
"states": {
"waiting": {"on": {"PROCESS": "processing"}},
"processing": {
"on": {
"ITEM_DONE": {"actions": "countItem"},
"STOP": "done"
}
},
"done": {"type": "final"}
}
}
child_logic = MachineLogic(
actions={
"countItem": lambda i, ctx, e, a: ctx.update(
{"items_processed": ctx["items_processed"] + 1}
)
}
)
child_machine = create_machine(child_config, logic=child_logic)
# Parent machine: spawns a processor and tracks it in context
parent_config = {
"id": "coordinator",
"initial": "idle",
"states": {
"idle": {
"on": {
"START": {
"target": "running",
"actions": "spawn_processor"
}
}
},
"running": {
"on": {
"DONE": "completed"
}
},
"completed": {"type": "final"}
}
}
parent_logic = MachineLogic(
services={"processor": child_machine}
)
machine = create_machine(parent_config, logic=parent_logic)
interp = SyncInterpreter(machine).start()
interp.send("START") # Spawns the processor actor
interp.stop()
Note: Communication between parent and child actors happens through the event system. The parent sends events that trigger child transitions. Actors created via
invokeautomatically fire the parent’sonDone/onErrorwhen they finish. Actors created via thespawn_/spawn_blocking_action-prefix convention — as in the example above — do not auto-notify the parent; the child must explicitlysendParenta completion event (as this example’sDONEtransition assumes) for the parent to react.
⚡ Actors with the Async Interpreter
Actors work seamlessly with the async Interpreter. The child machine is spawned as another Interpreter instance running its own event loop:
import asyncio
from xstate_statemachine import create_machine, Interpreter, MachineLogic
child_config = {
"id": "asyncWorker",
"initial": "running",
"states": {
"running": {"on": {"COMPLETE": "done"}},
"done": {"type": "final"}
}
}
child_machine = create_machine(child_config)
parent_config = {
"id": "asyncManager",
"initial": "ready",
"states": {
"ready": {
"on": {"SPAWN": {"actions": "spawn_worker"}}
}
}
}
parent_logic = MachineLogic(
services={"worker": child_machine}
)
async def main():
machine = create_machine(parent_config, logic=parent_logic)
interp = await Interpreter(machine).start()
await interp.send("SPAWN") # Spawns async child actor
await asyncio.sleep(0.1) # Give the actor time to start
await interp.stop() # Stops parent and all children
asyncio.run(main())
🧵 Complete Example: Task Manager with Worker Actors
This example demonstrates a task manager that spawns worker actors to process tasks concurrently:
from xstate_statemachine import create_machine, MachineLogic, SyncInterpreter
# --- Worker Machine ---
worker_config = {
"id": "worker",
"initial": "processing",
"context": {"task_id": None, "result": None},
"states": {
"processing": {
"on": {"FINISH": {"target": "completed", "actions": "saveResult"}}
},
"completed": {"type": "final"}
}
}
worker_logic = MachineLogic(
actions={
"saveResult": lambda i, ctx, e, a: ctx.update(
{"result": f"Task {ctx['task_id']} done"}
)
}
)
worker_machine = create_machine(worker_config, logic=worker_logic)
# --- Manager Machine ---
def create_task_worker(interpreter, context, event):
"""Factory: creates a worker configured for the specific task."""
task_id = event.payload.get("task_id", "unknown")
config = {
"id": f"worker-{task_id}",
"initial": "processing",
"context": {"task_id": task_id, "result": None},
"states": {
"processing": {
"on": {"FINISH": {"target": "completed", "actions": "saveResult"}}
},
"completed": {"type": "final"}
}
}
logic = MachineLogic(
actions={
"saveResult": lambda i, ctx, e, a: ctx.update(
{"result": f"Task {ctx['task_id']} completed"}
)
}
)
return create_machine(config, logic=logic)
def log_spawn(interpreter, context, event, action_def):
"""Track spawned tasks in the parent context."""
task_id = event.payload.get("task_id", "unknown")
context["active_tasks"].append(task_id)
print(f"Dispatched task: {task_id}")
manager_config = {
"id": "taskManager",
"initial": "accepting",
"context": {"active_tasks": []},
"states": {
"accepting": {
"on": {
"SUBMIT_TASK": {"actions": ["logSpawn", "spawn_taskWorker"]},
"SHUTDOWN": "shutDown"
}
},
"shutDown": {"type": "final"}
}
}
manager_logic = MachineLogic(
actions={"log_spawn": log_spawn},
services={"taskWorker": create_task_worker}
)
machine = create_machine(manager_config, logic=manager_logic)
interp = SyncInterpreter(machine).start()
interp.send("SUBMIT_TASK", task_id="001")
interp.send("SUBMIT_TASK", task_id="002")
print(f"Active tasks: {interp.context['active_tasks']}")
# Active tasks: ['001', '002']
interp.send("SHUTDOWN")
interp.stop()
✅ Best Practices for Actor Design
-
Keep actors self-contained — Each actor should have its own complete logic. Avoid tight coupling between parent and child.
-
Use factory functions for dynamic actors — When child machines depend on runtime data, use factory functions in
servicesrather than pre-builtMachineNodeinstances. -
Prefer
spawn_(non-blocking) for concurrent work — Non-blocking actors run in a background OS thread underSyncInterpreter; under the asyncInterpreterthey run concurrently as another coroutine on the same event loop. -
Use
spawn_blocking_sparingly — Blocking actors halt the parent’s event processing. Only use them when sequential completion is required. -
Clean up actors — The interpreter automatically stops all child actors when
stop()is called. For long-running actors, send explicit shutdown events before stopping the parent. -
Error handling — If the service lookup fails or the factory returns a non-
MachineNodevalue, anActorSpawningErroris raised. Always ensure your service keys match yourspawn_action names.
Warning: Async actions and services are not supported in
SyncInterpreter. If you need async actors, use the asyncInterpreterinstead.
↩️ Spawn Failures and Rollback
If a later action in the same transition raises after a spawn_ /
spawn_blocking_ action (or an invoke) has already succeeded, the interpreter
rolls back the transition — and stops the child it just created. This applies
on both the async Interpreter and SyncInterpreter, so a failed transition
never leaves an orphaned actor running in the background.
This only happens when actionErrorPolicy is "rollback" (or "fail"); under
the default "continue" policy the transition still commits and the spawned
child keeps running.
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
child_config = {"id": "child", "initial": "idle", "states": {"idle": {}}}
child_machine = create_machine(child_config)
def boom(interpreter, ctx, event, action):
raise RuntimeError("boom")
parent_config = {
"id": "parent",
"initial": "idle",
"context": {},
"actionErrorPolicy": "rollback",
"states": {
"idle": {
"on": {
"GO": {
"target": "running",
# spawn_child succeeds, then boom raises
"actions": ["spawn_child", "boom"]
}
}
},
"running": {}
}
}
logic = MachineLogic(
actions={"boom": boom},
services={"child": child_machine}
)
interp = SyncInterpreter(create_machine(parent_config, logic=logic)).start()
try:
interp.send("GO")
except RuntimeError:
pass
print(interp.current_state_ids) # still {'parent.idle'} -- the transition rolled back
print(interp._actors) # {} -- the spawned child was stopped, not orphaned