@xmachines/play-xstate
Documentation / @xmachines/play-xstate
XState v5 adapter for Play Architecture with signal-driven reactivity and routing
Transform declarative state machines into live actors with TC39 Signals and parameter-aware navigation.
Overview
@xmachines/play-xstate provides definePlayer(), the primary API for binding XState v5 state machines to the Play Architecture actor base. It enables business logic to control routing and state through guard-enforced transitions with catalog binding, signal lifecycle management, and XState DevTools compatibility.
Per RFC Play v1, this package implements:
- Actor Authority (INV-01): State machine guards decide navigation validity
- Strict Separation (INV-02): Zero React/framework imports in business logic
- Signal-Only Reactivity (INV-05): TC39 Signals expose all state changes
Routing: Supports meta.route patterns, play.route events with parameters, and route extraction.
Installation
npm install xstate@^5.0.0npm install @xmachines/play-xstatePeer dependencies:
xstate^5.0.0 — State machine runtime
zodis a direct dependency of@xmachines/play-xstate(not a peer). You do not need to install it separately unless you use it in your own catalog schemas.
Quick Start
import { setup } from "xstate";import { z } from "zod";import { definePlayer } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";
// 1. Define XState machine with meta.routeconst machine = setup({ types: { context: {} as { userId: string }, events: {} as { type: "play.route"; to: string } | { type: "auth.login"; userId: string }, }, guards: { isLoggedIn: ({ context }) => !!context.userId, },}).createMachine({ id: "app", initial: "login", context: { userId: "" }, states: { login: { id: "login", meta: { route: "/login", view: { component: "LoginForm" }, }, on: { "auth.login": { guard: "isLoggedIn", target: "dashboard", }, }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard", view: { component: "Dashboard", props: { userId: "" } }, }, }, },});
// 2. Define catalog with Zod schemasconst catalog = defineCatalog({ LoginForm: z.object({ error: z.string().optional() }), Dashboard: z.object({ userId: z.string() }),});
// 3. Create player factoryconst createPlayer = definePlayer({ machine, catalog });
// 4. Create and start actorconst actor = createPlayer({ userId: "" });actor.start();
// 5. Send events (play.route with parameters)actor.send({ type: "play.route", to: "/login" });
// 6. Observe signalsconsole.log(actor.currentRoute.get()); // "/login"console.log(actor.currentView.get()); // { component: "LoginForm", props: {...} }
// 7. Cleanupactor.dispose();API Reference
definePlayer()
Create a player factory from XState machine and catalog:
const createPlayer = definePlayer<TMachine, TCatalog>({ machine: AnyStateMachine, catalog?: Catalog, options?: PlayerOptions,}): PlayerFactory;Config:
machine(required) - XState v5 state machinecatalog(optional) - UI component catalog with Zod schemasoptions(optional) - Lifecycle hooks
Returns: Factory function (input?) => PlayerActor
Example:
const createPlayer = definePlayer({ machine: authMachine, catalog: authCatalog, options: { onStart: (actor) => console.log("Started:", actor.id), onTransition: (actor, prev, next) => { console.log("Transition:", prev.value, "→", next.value); }, },});
const actor1 = createPlayer({ userId: "user1" });const actor2 = createPlayer({ userId: "user2" });// Multiple independent actor instancesPlayerActor
Concrete actor implementing Play signal protocol:
Signal Properties:
state: Signal.State<AnyMachineSnapshot>— Reactive snapshot of current statecurrentRoute: Signal.Computed<string | null>— Derived navigation pathcurrentView: Signal.State<ViewMetadata | null>— Current UI structure (updated at state entry)
Actor Properties:
catalog: Catalog— Component catalog
Constructor:
new PlayerActor(machine, catalog, options, input?)input— Typed asInputFrom<TMachine>. Consumers receive compile-time validation against the machine’s input schema. Pass initial context values required by the machine.
Methods:
start()— Start the actor (must call after creation)stop()— Stop the actorsend(event: PlayEvent)— Send event to actordispose()— Convenience cleanup (calls stop())
Prop validation modes (via PlayerOptions.propValidation):
"lenient"(default) — On catalog prop validation failure, callsonErrorhook and renders with unvalidated props"strict"— On catalog prop validation failure, callsonErrorhook and setscurrentViewtonull(blocks render)
Example:
const actor = createPlayer();actor.start();
// Observe signals with watcherconst watcher = new Signal.subtle.Watcher(() => { queueMicrotask(() => { const route = actor.currentRoute.get(); console.log("Route changed:", route); });});watcher.watch(actor.currentRoute);actor.currentRoute.get(); // Initial readGuard Composition
import { composeGuards, composeGuardsOr, negateGuard, hasContext, eventMatches, stateMatches,} from "@xmachines/play-xstate";
const machine = setup({ guards: { isLoggedIn: hasContext("userId"), isAdmin: ({ context }) => context.role === "admin", },}).createMachine({ on: { accessAdmin: { // Array means AND - all guards must pass guard: composeGuards(["isLoggedIn", "isAdmin"]), target: "adminPanel", }, accessPublic: { // OR composition - any guard passes guard: composeGuardsOr(["isLoggedIn", ({ event }) => event.type === "guest.access"]), target: "publicArea", }, logout: { // NOT composition guard: negateGuard("isLoggedIn"), target: "login", }, },});Helpers:
hasContext(path: string)- Check if context property is truthyeventMatches(type: string)- Check event typestateMatches(value: string)- Check state valuecomposeGuards(guards: Array)- AND compositioncomposeGuardsOr(guards: Array)- OR compositionnegateGuard(guard)- NOT composition
Examples
Guard Placement Philosophy
Guards check if you can BE in a state (state entry), not if you can TAKE an event (event handlers).
import { setup } from "xstate";import { definePlayer, formatPlayRouteTransitions } from "@xmachines/play-xstate";import { defineCatalog } from "@xmachines/play-catalog";
// Pattern 1: RECOMMENDED - Use formatPlayRouteTransitions utilityconst machineConfig = { id: "app", initial: "home", context: { isAuthenticated: false }, states: { home: { id: "home", meta: { route: "/", view: { component: "Home" } }, }, dashboard: { id: "dashboard", meta: { route: "/dashboard", view: { component: "Dashboard" } }, // Always-guard validates state entry always: [ { target: "login", guard: ({ context }) => !context.isAuthenticated, }, ], }, login: { id: "login", meta: { route: "/login", view: { component: "Login" } }, }, },};
// formatPlayRouteTransitions handles routing infrastructureconst machine = setup({ types: { events: {} as { type: "play.route"; to: string } | { type: "auth.login" }, },}).createMachine(formatPlayRouteTransitions(machineConfig));
const catalog = defineCatalog({ Home, Dashboard, Login,});
const createPlayer = definePlayer({ machine, catalog });const actor = createPlayer();actor.start();
// Navigation via play.route eventactor.send({ type: "play.route", to: "/dashboard" });// Guard validates: Can I BE in dashboard state?// If !isAuthenticated → redirects to loginWhy this works:
formatPlayRouteTransitionsadds routing infrastructure (event.to → state mapping)- Always-guards handle business logic (authentication checks)
- Clear separation: routing is infrastructure, guards are business logic
Anti-pattern (DON’T DO THIS):
// ❌ WRONG - Guard on event checking event propertieson: { "play.route": { guard: ({ event }) => event.to === "/dashboard", target: "dashboard" }}Reference: See docs/examples/routing-patterns.md for canonical formatPlayRouteTransitions usage with always-guards for authentication.
Lifecycle Hooks
const createPlayer = definePlayer({ machine, catalog, options: { onStart: (actor) => { console.log("Actor started:", actor.id); }, onStop: (actor) => { console.log("Actor stopped:", actor.id); }, onTransition: (actor, prev, next) => { console.log("State change:", { from: prev.value, to: next.value, timestamp: Date.now(), }); }, onStateChange: (actor, state) => { // Called on every state update console.log("Snapshot updated:", state.value); }, onError: (actor, error) => { console.error("Actor error:", error); // Log to monitoring service, show error UI, etc. }, },});XState DevTools Integration
import { createBrowserInspector } from "@statelyai/inspect";import { definePlayer } from "@xmachines/play-xstate";
const { inspect } = createBrowserInspector();
const createPlayer = definePlayer({ machine, catalog });const actor = createPlayer();actor.start();
// PlayerActor maintains XState Inspector compatibility// Inspector displays:// - State transitions and values// - Context data// - Events sent to actor// - Guard evaluation results
// Signals accessible via actor properties, not snapshotsconsole.log(actor.currentRoute.get()); // "/dashboard"Metadata Conventions
Route Metadata
// meta.route marks states as routablestates: { dashboard: { id: "dashboard", meta: { route: "/dashboard", // URL path - marks state as routable }, },}
// Parametersmeta: { route: "/profile/:userId", // Required parameter route: "/settings/:section?", // Optional parameter}
// Inheritancemeta: { route: "/absolute", // Starts with / → doesn't inherit parent route route: "relative", // Doesn't start with / → inherits parent route}View Metadata
meta: { view: { component: "Dashboard", // Must exist in catalog props: { userId: "user123" }, // Validated against Zod schema title: "Dashboard", // Additional metadata },}
// Dynamic props from contextmeta: { view: { component: "Dashboard", props: (context) => ({ userId: context.userId, notifications: context.unreadCount, }), },}Architecture
This package implements RFC Play v1 requirements:
Architectural Invariants:
- Actor Authority (INV-01): Guards decide navigation validity
- Strict Separation (INV-02): Zero framework imports
- Signal-Only Reactivity (INV-05): All state via TC39 Signals
XState DevTools: Maintains Inspector compatibility — snapshots remain pure XState format, signals accessible via actor properties.
Routing:
meta.routeproperty marks states as routableplay.routeevents support parameters (enhancement)- Route extraction for URL patterns
Note: Route parameter extraction uses URLPattern API. See @xmachines/play-tanstack-react-router browser support for polyfill requirements.
Related Packages
- @xmachines/play-actor - AbstractActor base class
- @xmachines/play-signals - TC39 Signals polyfill
- @xmachines/play-catalog - UI schema validation
- @xmachines/play-router - Route extraction
- @xmachines/play - Protocol types (PlayEvent)
License
Copyright (c) 2016 Mikael Karon. All rights reserved.
This work is licensed under the terms of the MIT license.
For a copy, see https://opensource.org/licenses/MIT.
@xmachines/play-xstate - XState v5 adapter for Play Architecture
Provides definePlayer() API for binding XState state machines to the actor base with catalog binding, signal lifecycle, and DevTools integration.
Per RFC Play v1, this package implements the Logic Layer adapter that transforms declarative machine definitions into live actors with signal-driven reactivity.
Classes
Interfaces
Type Aliases
- Catalog
- ComposedGuard
- Guard
- GuardArray
- PlayerFactory
- RouteMachineConfig
- RouteStateNode
- ValidationResult
- ViewMergeContext