@xmachines/play-xstate
v6.1.0
Published
XState v5 adapter for Play Architecture
Maintainers
Readme
@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.
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
pnpm add @xmachines/play-xstate @xmachines/core @xmachines/play @xmachines/play-atom xstatePeer dependencies. Install them with the package:
@xmachines/core— the base package. This package reads the helpers of itsutilssubpath.@xmachines/play— the core protocol. This package reads the error classes from itserrorssubpath.@xmachines/play-atom— the atom primitives.PlayerActorholdscurrentViewandcurrentRouteas 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:
pnpm add @xmachines/play-router # @xmachines/play-xstate/routing
pnpm add @xmachines/play-view # @xmachines/play-xstate/view@xmachines/play-router— the routing capability readsRoutable,PlayRouteEventandRouteDatafrom it.@xmachines/play-view— the view capability readscomposePlayState,PlaySpecandViewablefrom it.
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 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 exposes reactive atoms. It holds the protocol of PlayActor and nothing else: state and send.
Atoms
| Atom | Type | Description |
| ------- | ------------------------------ | ---------------------------------------------------------------------------- |
| state | Atom<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:
pnpm add @xmachines/play-router # for @xmachines/play-xstate/routing
pnpm add @xmachines/play-view # for @xmachines/play-xstate/viewThe 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.
| Capability | Entry point | Adds | Interface |
| ---------- | -------------------------------- | ------------------------------ | ------------------------------------------------------------------ |
| Routing | @xmachines/play-xstate/routing | currentRoute, initialRoute | Routable of @xmachines/play-router |
| View | @xmachines/play-xstate/view | currentView | Viewable 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 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;| Atom | Type | Capability | Description |
| -------------- | -------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| currentRoute | ReadonlyAtom<string \| null> | routing | The URL from the meta.route template of the active state and from the context. A parallel machine uses its first routed region |
| initialRoute | readonly string \| null | routing | The 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 |
| currentView | ReadonlyAtom<PlaySpec \| null> | view | The meta.view of the deepest active state, or the composed view of a state that declares outlets, with the context under /context |
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.
A parent view with outlets shares the screen with its substates. The derivation then
walks the active states from the root, puts the view of each active child into its outlet,
and keeps the viewKey of the outermost composing view, so a change of the page keeps the
store of the layout. A machine that declares no outlets keeps the rule that the deepest
view wins. The State Machines guide
shows a complete machine.
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
| 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 |
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.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 |
A scope that owns an actor
PlayerActor carries no dispose key. A scope that owns an actor publishes its stop()
with asDisposable of @xmachines/core/utils:
import { asDisposable } from "@xmachines/core/utils";
{
const actor = definePlayer({ machine })();
using _stop = asDisposable(() => {
actor.stop();
});
actor.start();
} // stop() runs here, and after an exception tooInspection
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
} 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 does not re-export them: 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
// under `reenter: false`, and a route from outside the parent
// arrives NOWHERE.
// "both" — one in each place. The parent keeps its entry action under
// `reenter: false`, and a route from outside the parent still arrives.
handler: "both",
// Whether the transition re-enters its 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.
The
reenterflag re-enters the domain, and not the target. The target state runs its ownexitandentryactions on each navigation to it, under both values. A state that declaresreenter: truealso re-enters the domain of its transition: the root underhandler: "root", and the parent under"local"and"both"while the parent is active. A route to the ROOT of the machine is the one exception: the root is then the target and the domain, so the root runs those actions only underreenter: true.
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.
