Skip to content

The Actor Model in XMachines

XMachines is built around one central design principle: business logic must never depend on infrastructure. This page explains what that means, why the split is enforced the way it is, and what concrete responsibilities belong to each side of the boundary.

After reading this, you will understand why a state machine in XMachines has no import from React, Vue, or any router — and why that constraint is a feature, not a limitation.


Two roles, one boundary

Every XMachines application has exactly two roles:

The Actor — owns state, guards, routing intent, and view structure. It has zero knowledge of browsers, routing libraries, or UI frameworks.

Infrastructure — the runtime environment. It reflects actor state into the world and forwards world events to the actor. It has no opinion on whether a transition is valid.

The boundary between them is enforced by the atom protocol: the actor exposes Atom and ReadonlyAtom properties; infrastructure observes them. The actor never calls into infrastructure; infrastructure never mutates actor state directly.

Actor (business logic)
│
│ emits atoms: state, and currentRoute / currentView from its capabilities
▼
Infrastructure (runtime adapters)
├── Router Bridge — reflects currentRoute into the URL bar
├── Renderer — renders currentView into framework components
└── ... (any other observer)
Infrastructure → actor: only via actor.send({ type: "..." })

What the actor owns

An actor in XMachines is a PlayerActor — a concrete class that extends XState’s Actor class via PlayActor. It implements three reactive properties:

PropertyTypeWhat it is
actor.stateAtom<Snapshot>The ONE atom the actor writes. Updated on every XState transition, with the full machine snapshot.
actor.currentRouteReadonlyAtom<string | null>DERIVED from state, through the active state node’s meta.route. Needs the routing capability.
actor.currentViewReadonlyAtom<PlaySpec | null>DERIVED from state. Holds the PlaySpec that drives renderers. Needs the view capability.

Infrastructure reads all three and writes none of them.

One write for each transition, and two derivations from it. state receives the new snapshot, and the engine then evaluates currentRoute and currentView from it in one propagation. An observer that reads two of these atoms therefore never sees the new state beside an old derivation, and the composition order of the capabilities decides nothing.

A derivation that fails reports through the onError option of the actor and keeps its last good value. It throws never, because it runs inside the write and a throw would break the transition for every other observer. See Atoms.

The actor also owns:

  • Guards — it decides whether a transition is valid. If a router sends a play.route event for a path the actor’s guards reject, the actor does not transition. It then emits its current valid route back through currentRoute, and the router bridge overwrites the URL to match.
  • Error states — structured errors (PlayError) are part of the actor’s state graph, not thrown into the environment.
  • Initial route — actor.initialRoute is the route the actor starts in. Router adapters use this for initial navigation, not the browser’s current URL.

What infrastructure owns

Infrastructure owns everything that touches the environment:

  • Pushing URLs: reading actor.currentRoute and calling history.pushState() or a router’s navigation API.
  • Forwarding navigation events: listening to popstate, routeChange, or equivalent, then calling actor.send({ type: "play.route", to: path }).
  • Rendering: reading actor.currentView and projecting the spec through framework components.
  • Lifecycle: connecting on mount, disconnecting on unmount, explicitly cleaning up atom subscriptions.

Infrastructure does not:

  • Decide whether a play.route event is valid (that is the actor’s job via guards).
  • Override the URL to something the actor did not emit.
  • Read actor.getSnapshot() to make routing decisions (use actor.currentRoute instead).

The reset invariant

One of the most important behaviors in XMachines falls out directly from this split.

If a user navigates directly to /admin in the browser but the actor’s guards reject that route (because the user is not authenticated), the actor:

  1. Receives { type: "play.route", to: "/admin" } from the router bridge.
  2. Evaluates its guards. The guard fails.
  3. Does not transition. It remains in its current state (e.g., /login).
  4. actor.currentRoute still holds "/login".
  5. The router bridge, watching currentRoute, calls its navigateRouter("/login") method.
  6. The URL bar resets to /login.

The environment is always made to match actor reality. The router bridge does not need any special “auth guard” logic — the state machine already has it.

This is why the invariant is called State-Driven Reset in the Play RFC.


PlayActor — the enforced contract

PlayActor (from @xmachines/play-actor) is the contract that all actor implementations satisfy. It is an INTERFACE, and it names no state machine library.

An adapter extends the actor class of its own engine. PlayerActor of @xmachines/play-xstate extends Actor of XState, which means:

PlayActor asks for two members: a reactive state property of type Atom<TSnapshot>, and a typed send. state is the single point where the pull-based snapshot API of an engine is bridged to the push-based atom system.

The two optional capability interfaces — Routable and Viewable — are deliberately separate:

  • Not every actor needs routing (e.g., a background data-sync actor).
  • Not every actor drives a view (e.g., a sub-actor composed inside a parent).

An actor implements only the capabilities it actually uses:

// Minimal actor: the contract alone, which is reactive state and send
class MinimalActor implements PlayActor<SomeSnapshot, SomeEvent> {
readonly state = createAtom<SomeSnapshot>(initial);
send(event: SomeEvent): void {
/* dispatch */
}
}
// Full actor: the contract, the routing and the view. It extends the actor class of
// its own engine, and `PlayActor` stays out of the inheritance.
class PlayerActor
extends Actor<SomeMachine>
implements PlayActor<SomeSnapshot, SomeEvent>, Routable, Viewable
{
readonly state = createAtom(this.getSnapshot());
readonly currentRoute = createAtom(() => deriveRoute(this.state.get()));
readonly currentView = createAtom((): PlaySpec | null => deriveView(this.state.get()));
}

In practice you never write PlayerActor yourself — definePlayer from @xmachines/play-xstate creates one from your machine definition.


definePlayer — the factory builder

definePlayer({ machine }) is the primary entry point for creating actors:

import { definePlayer } from "@xmachines/play-xstate";
import { myMachine } from "./machine.js";
const createPlayer = definePlayer({ machine: myMachine });
// Each call creates an independent actor instance
const actor = createPlayer();
actor.start();

definePlayer returns a factory function, not an actor. This is intentional: it lets you create multiple independent instances (e.g., one per test, one per user session) without re-processing the machine definition each time.

The factory accepts optional input (initial context overrides) and a restore snapshot (for resumable sessions):

const actor = createPlayer({ userId: "abc" }); // with input
const actor = createPlayer(undefined, { snapshot }); // restored from snapshot

Why the actor has no framework imports

The machine definition — the setup().createMachine(...) call — is pure TypeScript with zero runtime dependencies on browsers, routers, or UI frameworks. This is not a convention; it is enforced by the dependency graph.

@xmachines/play-xstate depends on xstate and @xmachines/play-atom. It does not depend on React, Vue, any router library, or any browser API. Your machine file inherits those same boundaries.

This has concrete practical benefits:

  • Portable: the same machine runs in a browser, a Node.js server, a web worker, and a Vitest test suite — with zero modification.
  • Testable in isolation: you test the machine with actor.send() and actor.state.get(). No DOM, no router, no React to mock.
  • Replaceable infrastructure: swap the React renderer for a Vue renderer, or swap TanStack Router for React Router, without touching the machine file.

The lock statement

The Play RFC captures the actor/infrastructure split in a single statement:

Logic is sovereign. Infrastructure reflects, never decides. Capabilities compose, never prescribe. Logic owns structure and flow. Adapters project, never decide. This is Universal Player.


See also