@xmachines/play-xstate
API / @xmachines/play-xstate
XState v5 adapter for the XMachines Play Architecture. It binds a state machine to the actor base, with signal-driven reactivity and a router integration.
Installation
pnpm add @xmachines/play-xstate xstatexstate ^5.31.0 is a peer dependency. Install it with this package.
Quick Start
import { setup } from "xstate";import { definePlayer } from "@xmachines/play-xstate";
// 1. Define your XState v5 machineconst machine = setup({}).createMachine({ initial: "idle", states: { idle: { meta: { route: "/" }, on: { activate: "active" } }, active: { meta: { route: "/active" } }, },});
// 2. Create a player factoryconst createPlayer = definePlayer({ machine });
// 3. Instantiate and start an actorconst actor = createPlayer();actor.start();
// 4. Observe TC39 Signal-based reactive stateconsole.log(actor.currentRoute.get()); // "/"console.log(actor.state.get().value); // "idle"
// 5. Send events — machine guards decide transitionsactor.send({ type: "activate" });
actor.stop();API Summary
definePlayer(config)
This function creates a PlayerFactory from an XState v5 machine. One configuration can therefore make more than one independent actor instance. This helps with a multi-user application, with SSR, and with a test.
import { setup } from "xstate";import { definePlayer } from "@xmachines/play-xstate";
const machine = setup({ types: { context: {} as { userId: string }, input: {} as { userId: string }, },}).createMachine({ context: ({ input }) => ({ userId: input.userId }), initial: "home", states: { home: {} },});
const createPlayer = definePlayer({ machine, options: { onStart: (actor) => console.log("started"), onStop: (actor) => console.log("stopped"), onTransition: (actor, prev, next) => console.log("transitioned"), onStateChange: (actor, state) => console.log("state changed"), onError: (actor, err) => console.error(err), inspect: (event) => console.log(event.type), // handed to XState's actor constructor — enables @statelyai/inspect },});
// Each call returns an independent PlayerActor instanceconst alice = createPlayer({ userId: "alice" });const bob = createPlayer({ userId: "bob" });PlayerFactory signature
The input argument follows the rule of createActor in XState. If the input type of a
machine cannot be undefined, the first argument of the factory is necessary. An absent
input is then a compile error, not an actor that stops in an error status.
type PlayerFactory<TMachine> = undefined extends InputFrom<TMachine> ? ( input?: InputFrom<TMachine>, options?: PlayerFactoryResumeOptions<TMachine>, ) => PlayerActor<TMachine> : ( input: InputFrom<TMachine>, options?: PlayerFactoryResumeOptions<TMachine>, ) => PlayerActor<TMachine>;Restoring from a snapshot
const snapshot = actor.getPersistedSnapshot();actor.stop();
// Restore to the exact saved stateconst restored = createPlayer({ userId: "alice" }, { snapshot });restored.start();console.log(restored.currentRoute.get()); // same route as when savedNote: persist the state with
getPersistedSnapshot(), not withgetSnapshot().createActoraccepts that form only, and it is the only form that restores a machine with an invoked child or a spawned child.
PlayerActor<TMachine>
This concrete actor class is an XState v5 actor that also exposes reactive TC39 Signals. It implements both the Routable interface and the Viewable interface from @xmachines/play-actor.
Signals
| Signal | Type | Description |
|---|---|---|
state | Signal.State<SnapshotFrom<TMachine>> | The current XState snapshot. The actor updates it on every active transition |
currentRoute | Signal.Computed<string | null> | The URL that comes from the meta.route template of the active state and from the context |
currentView | Signal.State<PlaySpec | null> | The view spec from the meta.view metadata of the active state, with the context params added |
initialRoute | readonly string | null | The route of the initial state of the machine. The constructor fixes it, and a router bridge uses it to detect a deep link or a restore |
Methods
| Method | Description |
|---|---|
start() | Starts the actor and calls the onStart hook |
stop() | Stops the actor, cleans up the subscriptions, and calls the onStop hook |
send(event) | Sends a typed event to the machine and calls the onTransition hook |
can(event) | Returns true when the current state accepts the given event |
getSnapshot() | Returns the current XState snapshot |
dispose() | The alias of stop() |
Signal usage example
import { Signal } from "@xmachines/play-signals";
const watcher = new Signal.subtle.Watcher(() => { queueMicrotask(() => { console.log("Route changed:", actor.currentRoute.get()); });});
watcher.watch(actor.currentRoute);actor.start();Guard utilities
Deprecated: these guard helpers wrap the
and(),or(), andnot()combinators of XState, but they do not compose with a guard slot thatsetup()types. Use the combinators of XState directly. The next major version removes this module.
These composable guard helpers wrap the and(), or(), and not() functions of XState. Use them in a machine setup({ guards }) definition.
import { setup } from "xstate";import { composeGuards, // AND logic: all guards must pass composeGuardsOr, // OR logic: at least one guard must pass negateGuard, // NOT logic: inverts a guard hasContext, // guard: context field is present and non-null eventMatches, // guard: event type matches a string contextFieldMatches, // guard: context field equals a value} from "@xmachines/play-xstate";
const machine = setup({ guards: { isLoggedIn: ({ context }) => !!context.userId, hasAdminRole: ({ context }) => context.role === "admin", },}).createMachine({ on: { accessAdmin: { guard: composeGuards(["isLoggedIn", "hasAdminRole"]), target: "adminPanel", }, accessLogin: { guard: negateGuard("isLoggedIn"), target: "login", }, }, // ...});Routing utilities
These helper functions configure the routes of an XState machine declaratively.
formatPlayRouteTransitions(machineConfig)
This function reads each machine state that has a meta.route field. It then generates the play.route event handlers at the root level. You therefore write no repetitive routing transition.
import { setup } from "xstate";import { formatPlayRouteTransitions } from "@xmachines/play-xstate";
const config = formatPlayRouteTransitions({ id: "app", states: { home: { id: "home", meta: { route: "/home" }, }, profile: { id: "profile", meta: { route: "/users/:userId" }, }, },});
// config now includes auto-generated play.route handlers:// on: { "play.route": [ { target: ".home", guard: e => e.to === "#home" }, ... ] }const machine = setup({}).createMachine(config);Note: every state with a
meta.routefield must also have an explicitidfield. A state without anidfield throwsMissingStateIdErrorwhen you define the machine.
Other routing exports
| Export | Description |
|---|---|
deriveRoute(meta) | Reads the route template string from the metadata object of a state |
isAbsoluteRoute(route) | Returns true when the route string is an absolute URL path |
buildRouteUrl(template, context) | Replaces each :param placeholder of a route template with a context value |
Inspection
The factory gives options.inspect to createActor of XState without a change.
Therefore every XState inspection tool works with a PlayerActor, and this
includes @statelyai/inspect:
import { createBrowserInspector } from "@statelyai/inspect";
const { inspect } = createBrowserInspector();const createPlayer = definePlayer({ machine, options: { inspect } });Three points are important:
inspectis an option of the factory, not of one instance. Every actor of a factory reports to the same observer. Separate the actors by root:event.rootId === actor.sessionIdcovers the complete tree of an actor, with its children.inspectis the only path that sees the construction.actor.system.inspect(fn)attaches later, and it sees only the events after that moment. It therefore misses the@xstate.actorregistration, and an inspector needs that registration to draw the machine.- A
PlayerActoris the actor. Its own events carryactorRef === playerActor, so you can recognize a player by its identity. Note one point:@xstate.actorfires from inside the constructor, andstate,currentRoute,currentView, andinitialRoutedo not exist yet. A read of one of them there throws.
For an inspector that you create after the factory, such as a dev-tools switch, give the
factory a function that forwards each event: inspect: (event) => currentInspector?.(event).
The inspector guide gives the complete procedure: a late attachment with a replay, a WebSocket inspection without a browser, and the points to consider in production.
Exported Types
import type { PlayerConfig, // definePlayer() config argument shape PlayerOptions, // Lifecycle hooks (onStart, onStop, onTransition, onStateChange, onError) + inspect PlayerFactory, // Factory function returned by definePlayer() PlayerFactoryResumeOptions, // { snapshot? } for restoring actor state Guard, // deprecated with the guard utilities — removed in the next major GuardArray, // deprecated with the guard utilities — removed in the next major ComposedGuard, // deprecated with the guard utilities — removed in the next major RouteMachineConfig, // Minimal machine config accepted by formatPlayRouteTransitions RouteStateNode, // Single state node shape used during route crawling RouteContext, // Context shape expected by buildRouteUrl ({ params?, query?, basePath?, hash? }) RouteObject, // Route metadata object shape: { path: string } RouteMetadata, // Union: string | RouteObject} from "@xmachines/play-xstate";Error Classes
The @xmachines/play-xstate/errors subpath exports the error classes. The main bundle therefore stays small.
import { MissingRouteParamError, // Required :param absent from context when resolving currentRoute MissingQueryContextError, // deprecated: no longer thrown MissingStateIdError, // meta.route declared without a state id field InvalidMachineError, // PlayerActor constructed with a non-object machine InvalidEventError, // actor.send() called with null/undefined/non-object ActorThrewNonErrorError, // actor failed with a thrown value that is not an Error InvalidRouteMetadataError, // meta.route is neither a string nor { path: string } EmptyGuardArrayError, // composeGuards/composeGuardsOr called with empty array} from "@xmachines/play-xstate/errors";Every error class extends PlayError from @xmachines/play. Each class also carries typed detail fields, such as param, template, and combinator. Your code therefore reads the details of an error, and it does not parse the message.
Testing
# Run tests for this package in isolationpnpm --filter @xmachines/play-xstate test
# Watch modepnpm --filter @xmachines/play-xstate run test:watchThe tests use Vitest. They are in packages/play-xstate/test/.
License
MIT — see LICENSE for details.
@xmachines/play-xstate - the XState v5 adapter of the Play Architecture
This package gives you the definePlayer() API. That function binds an XState state machine to the actor base, with the signal lifecycle and the DevTools integration.
The Play RFC gives this package as the adapter of the logic layer. It converts a declarative machine definition into a live actor with a signal-driven reactivity.