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 atom-driven reactivity and a router integration.

License: MIT Version


Browser floor: Chrome 110, Firefox 115, Safari 16.4. This package calls the ES2023 change-by-copy array methods, so a browser below that floor throws TypeError: ... is not a function. The root README carries the table.

Installation

Terminal window
pnpm add @xmachines/play-xstate @xmachines/play @xmachines/play-atom xstate

Peer dependencies. Install them with the package:

  • @xmachines/play — the core protocol. This package reads shallowEqualExcept and DISPOSE from it.
  • @xmachines/play-atom — the atom primitives. PlayerActor holds currentView and currentRoute as atoms.
  • xstate ^5.33.0 — the XState v5 runtime.

Optional peers. Each capability subpath carries one. Install it when you read that subpath, and not before:

Terminal window
pnpm add @xmachines/play-router # @xmachines/play-xstate/routing
pnpm add @xmachines/play-view # @xmachines/play-xstate/view

Quick Start

import { setup } from "xstate";
import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";
import { withRouting } from "@xmachines/play-xstate/routing";
// 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, actor: compose(PlayerActor, withRouting) });
// 3. Instantiate and start an actor
const actor = createPlayer();
actor.start();
// 4. Observe atom-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 exposes reactive atoms. It holds the protocol of PlayActor and nothing else: state and send.

Atoms

AtomTypeDescription
stateAtom<SnapshotFrom<TMachine>>The current XState snapshot. The actor updates it on every active transition

The capabilities

Routing and the view are OPTIONAL, and each one is a mixin behind its own entry point. An application that declares no route loads no routing code, and an application that renders no view loads no view code and installs no json-render.

Each capability package is an OPTIONAL peer dependency. Install the one that you compose:

Terminal window
pnpm add @xmachines/play-router # for @xmachines/play-xstate/routing
pnpm add @xmachines/play-view # for @xmachines/play-xstate/view

The main entry point of this package names neither. @xmachines/play-xstate/routing also carries the route utilities — deriveRoute, isAbsoluteRoute, buildRouteUrl, formatPlayRouteTransitions and the route types — because each one names @xmachines/play-router in its own types.

CapabilityEntry pointAddsInterface
Routing@xmachines/play-xstate/routingcurrentRoute, initialRouteRoutable of @xmachines/play-router
View@xmachines/play-xstate/viewcurrentViewViewable of @xmachines/play-view

compose applies each capability from left to right:

import { definePlayer, PlayerActor, compose } from "@xmachines/play-xstate";
import { withRouting } from "@xmachines/play-xstate/routing";
import { withView } from "@xmachines/play-xstate/view";
// Both capabilities
const createPlayer = definePlayer({
machine,
actor: compose(PlayerActor, withRouting, withView),
});
// Routing alone: `currentView` is a compile error on this actor
const createRouted = definePlayer({ machine, actor: compose(PlayerActor, withRouting) });
// Neither: `state` and `send`
const createBare = definePlayer({ machine });

The composition ORDER decides nothing. currentRoute and currentView are computed atoms over state, and neither reads the other, so one write for each transition reaches both and the engine evaluates them in topological order. A router bridge therefore sees a guard redirect and a renderer sees the view of the same snapshot, whatever order compose applied. A release before this one wrote currentView from an onSnapshot override, and the order was load-bearing there.

A lifecycle hook receives the bare actor. PlayerOptions is typed before the class is composed, so it cannot name the capabilities, and a narrower hook parameter is refused under strictFunctionTypes. A hook that reads currentRoute or currentView reads them through a binding of the composed type:

let composed: PlayerActor<typeof machine> & Routable & Viewable;
const actor = definePlayer({
machine,
options: { onStateChange: () => console.log(composed.currentRoute.get()) },
actor: compose(PlayerActor, withRouting, withView),
})();
composed = actor;
AtomTypeCapabilityDescription
currentRouteReadonlyAtom<string | null>routingThe URL that comes from the meta.route template of the active state and from the context
initialRoutereadonly string | nullroutingThe route of the initial state of the machine. The construction fixes it, and a router bridge uses it to detect a deep link or a restore
currentViewReadonlyAtom<PlaySpec | null>viewThe view spec from the meta.view metadata of the active state, with the context params added

state is the ONE atom that the actor writes. currentRoute and currentView are COMPUTED over it, so one write for each transition reaches both in one propagation and no observer reads the new state beside an old derivation. Each derivation reports a failure through the onError option and keeps its last good value; it throws never, because it runs inside the write.

currentRoute notifies on every transition, and currentView notifies on a change of the view alone. The two atoms answer two different questions. A router bridge needs the answer of the actor to each event that it proposed, and a guard that refuses an inbound location and holds the machine where it was derives the SAME path — so currentRoute compares “different” always, and the bridge learns that the address bar must go back. currentView gates its emission on the value, and a snapshot that changes no view on the screen keeps the previous reference, so a provider below it mounts the UI again not on every event. Debounce your own watchAtom(actor.currentRoute, ...) callback, or compare the path yourself, when you run work that belongs to a change of the route alone.

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]()Stops the actor at the end of a using scope. The release is stop()

Atom usage example

import { watchAtom } from "@xmachines/play-atom";
const stop = watchAtom(actor.currentRoute, (route) => {
console.log("Route changed:", route);
});
actor.start();
// Later, on teardown
stop();

Guards

This package gives no guard utility. Compose a guard with the and(), or() and not() combinators of XState:

import { and, not, setup } from "xstate";
const machine = setup({
guards: {
isLoggedIn: ({ context }) => context.userId !== "",
hasAdminRole: ({ context }) => context.role === "admin",
},
}).createMachine({
on: {
accessAdmin: {
guard: and(["isLoggedIn", "hasAdminRole"]),
target: "adminPanel",
},
accessLogin: {
guard: not("isLoggedIn"),
target: "login",
},
},
// ...
});

A combinator resolves each name against the guards of setup(). A name that the map does not hold therefore fails to compile, and it names the fault: Type '"typoGuardName"' is not assignable to type '"isLoggedIn"'.

composeGuards, composeGuardsOr, negateGuard, hasContext, eventMatches and contextFieldMatches stood here before. They closed two gaps of the types of XState 5.28. The peer floor of this package is ^5.33.0, and 5.33 holds neither gap. The helpers also fitted a guard slot that setup() types never: the example of this package reached them through an as never cast. Write the combinator instead, and write a plain predicate in place of each helper.


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/routing";
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

The release protocol

DISPOSE comes from @xmachines/play, and this package re-exports it, because PlayerActor carries [DISPOSE]: a scope releases an actor with using and writes no teardown.


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
} from "@xmachines/play-xstate";
import type {
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, reenter?, handler?, data? }
RouteMetadata, // Union: string | RouteObject
RouteData, // The resolved extra data of a route: Record<string, unknown>
RouteDataResolver, // The function form of RouteObject.data
} from "@xmachines/play-xstate/routing";

RouteObject, RouteMetadata, RouteData, and RouteDataResolver have one definition, and it lives in @xmachines/play-router. @xmachines/play-xstate/routing re-exports them, so either import path gives you the same type. The MAIN entry point of this package re-exports them no longer: it would name an optional peer in the types of every consumer, including one that composes no routing.

The object form of meta.route

The string form declares the path only. The object form declares the path and the behaviour of the generated play.route transition:

meta: {
route: {
path: "/doc/:docId",
// Where the generated transition sits. The default is "root", which is what
// XState does: every route transition sits on the root of the machine.
// "root" — one transition on the root. A route from any state arrives.
// "local" — one transition on the PARENT. The parent keeps its entry action,
// and a route from outside the parent arrives NOWHERE.
// "both" — one in each place. The parent keeps its entry action, and a route
// from outside the parent still arrives.
handler: "both",
// Whether the transition re-enters its OWN DOMAIN. The default is false, which
// is the default of XState. Under `handler: "root"` the domain is the root of
// the machine, so `false` spares the root alone: each ancestor between the root
// and the target still runs its exit and its entry actions. `handler` is the
// field that spares those intermediate ancestors.
reenter: false,
// The extra data of the route. The generated transition assigns it to
// `context.data`. A literal value, or a function of { context, event }.
data: { titleKey: "doc.view" },
},
}

An unknown handler value throws InvalidRouteHandlerError at the format time.

Upgrading from 3.x: the reenter default changed, and a machine that relies on it goes quiet. An earlier release wrote reenter: true on EVERY generated transition, so every routed state re-entered its own domain on each navigation and ran its entry actions again. The flag is now what meta.route declares, and the default is false — which is the default of XState. A state whose entry action must run on each navigation has to declare reenter: true for itself. Nothing throws, and the action simply stops running.

The data field follows the WithDynamicParams shape of XState, so the function form reads the context and the event:

data: ({ context, event }) => ({ title: `Document ${event.params?.docId}` });

A function does not survive JSON.stringify. Use the literal form for a machine that Stately Studio reads, or that a process sends over a wire.


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
InvalidRouteParamError, // A :param carries a dot segment, which a URL resolves away
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 }
InvalidRouteHandlerError, // meta.route.handler is not "root", "local", or "both"
} from "@xmachines/play-xstate/errors";

Every error class extends PlayError from @xmachines/play/errors. Each class also carries typed detail fields, such as param, template, and handler. Your code therefore reads the details of an error, and it does not parse the message.


License

MIT — see LICENSE for details.

Modules