Skip to content

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

License: MIT Version


Installation

Terminal window
pnpm add @xmachines/play-xstate xstate

xstate ^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 machine
const machine = setup({}).createMachine({
initial: "idle",
states: {
idle: { meta: { route: "/" }, on: { activate: "active" } },
active: { meta: { route: "/active" } },
},
});
// 2. Create a player factory
const createPlayer = definePlayer({ machine });
// 3. Instantiate and start an actor
const actor = createPlayer();
actor.start();
// 4. Observe TC39 Signal-based reactive state
console.log(actor.currentRoute.get()); // "/"
console.log(actor.state.get().value); // "idle"
// 5. Send events — machine guards decide transitions
actor.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 instance
const 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 state
const restored = createPlayer({ userId: "alice" }, { snapshot });
restored.start();
console.log(restored.currentRoute.get()); // same route as when saved

Note: persist the state with getPersistedSnapshot(), not with getSnapshot(). createActor accepts 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

SignalTypeDescription
stateSignal.State<SnapshotFrom<TMachine>>The current XState snapshot. The actor updates it on every active transition
currentRouteSignal.Computed<string | null>The URL that comes from the meta.route template of the active state and from the context
currentViewSignal.State<PlaySpec | null>The view spec from the meta.view metadata of the active state, with the context params added
initialRoutereadonly string | nullThe 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

MethodDescription
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(), and not() combinators of XState, but they do not compose with a guard slot that setup() 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.route field must also have an explicit id field. A state without an id field throws MissingStateIdError when you define the machine.

Other routing exports

ExportDescription
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:

  • inspect is 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.sessionId covers the complete tree of an actor, with its children.
  • inspect is 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.actor registration, and an inspector needs that registration to draw the machine.
  • A PlayerActor is the actor. Its own events carry actorRef === playerActor, so you can recognize a player by its identity. Note one point: @xstate.actor fires from inside the constructor, and state, currentRoute, currentView, and initialRoute do 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

Terminal window
# Run tests for this package in isolation
pnpm --filter @xmachines/play-xstate test
# Watch mode
pnpm --filter @xmachines/play-xstate run test:watch

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

Classes

Interfaces

Type Aliases

Functions