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 MachineNode instance (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_worker maps to the service key worker. The interpreter strips the spawn_ 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. Use spawn_ (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 factorycontext: 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 ActorSpawningErrorsystemIds 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 invoke fires the parent’s onDone only when it reaches a top-level final state, and onError if it ends in an error. The done.invoke.<id> payload is the child’s declared output (machine-level output wins over the final state’s), never its raw context (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_INTERVAL no longer exists.

Unresolvable to (0.8.1, #133). A sendTo whose to names no live actor does not raise. The send is dropped, on_event_dropped(reason="unresolved_target") fires, and the step is marked: last_transition_ok is False and receipt.error / last_error carry an ActorSpawningError naming the target. escalate from an invoked child reaches the parent’s onError (#130).

Threading (sync engine, 0.8.1). A non-blocking spawnChild on SyncInterpreter starts 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 invoke automatically fire the parent’s onDone/onError when they finish. Actors created via the spawn_/spawn_blocking_ action-prefix convention — as in the example above — do not auto-notify the parent; the child must explicitly sendParent a completion event (as this example’s DONE transition 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

  1. Keep actors self-contained — Each actor should have its own complete logic. Avoid tight coupling between parent and child.

  2. Use factory functions for dynamic actors — When child machines depend on runtime data, use factory functions in services rather than pre-built MachineNode instances.

  3. Prefer spawn_ (non-blocking) for concurrent work — Non-blocking actors run in a background OS thread under SyncInterpreter; under the async Interpreter they run concurrently as another coroutine on the same event loop.

  4. Use spawn_blocking_ sparingly — Blocking actors halt the parent’s event processing. Only use them when sequential completion is required.

  5. 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.

  6. Error handling — If the service lookup fails or the factory returns a non-MachineNode value, an ActorSpawningError is raised. Always ensure your service keys match your spawn_ action names.

Warning: Async actions and services are not supported in SyncInterpreter. If you need async actors, use the async Interpreter instead.

↩️ 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

See also Interpreters — Lifecycle: completion and teardown.