Skip to content

@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

Terminal window
npm install xstate@^5.0.0
npm install @xmachines/play-xstate

Peer dependencies:

  • xstate ^5.0.0 — State machine runtime

zod is 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.route
const 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 schemas
const catalog = defineCatalog({
LoginForm: z.object({ error: z.string().optional() }),
Dashboard: z.object({ userId: z.string() }),
});
// 3. Create player factory
const createPlayer = definePlayer({ machine, catalog });
// 4. Create and start actor
const actor = createPlayer({ userId: "" });
actor.start();
// 5. Send events (play.route with parameters)
actor.send({ type: "play.route", to: "/login" });
// 6. Observe signals
console.log(actor.currentRoute.get()); // "/login"
console.log(actor.currentView.get()); // { component: "LoginForm", props: {...} }
// 7. Cleanup
actor.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 machine
  • catalog (optional) - UI component catalog with Zod schemas
  • options (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 instances

PlayerActor

Concrete actor implementing Play signal protocol:

Signal Properties:

  • state: Signal.State<AnyMachineSnapshot> — Reactive snapshot of current state
  • currentRoute: Signal.Computed<string | null> — Derived navigation path
  • currentView: 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 as InputFrom<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 actor
  • send(event: PlayEvent) — Send event to actor
  • dispose() — Convenience cleanup (calls stop())

Prop validation modes (via PlayerOptions.propValidation):

  • "lenient" (default) — On catalog prop validation failure, calls onError hook and renders with unvalidated props
  • "strict" — On catalog prop validation failure, calls onError hook and sets currentView to null (blocks render)

Example:

const actor = createPlayer();
actor.start();
// Observe signals with watcher
const watcher = new Signal.subtle.Watcher(() => {
queueMicrotask(() => {
const route = actor.currentRoute.get();
console.log("Route changed:", route);
});
});
watcher.watch(actor.currentRoute);
actor.currentRoute.get(); // Initial read

Guard 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 truthy
  • eventMatches(type: string) - Check event type
  • stateMatches(value: string) - Check state value
  • composeGuards(guards: Array) - AND composition
  • composeGuardsOr(guards: Array) - OR composition
  • negateGuard(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 utility
const 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 infrastructure
const 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 event
actor.send({ type: "play.route", to: "/dashboard" });
// Guard validates: Can I BE in dashboard state?
// If !isAuthenticated → redirects to login

Why this works:

  • formatPlayRouteTransitions adds 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 properties
on: {
"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 snapshots
console.log(actor.currentRoute.get()); // "/dashboard"

Metadata Conventions

Route Metadata

// meta.route marks states as routable
states: {
dashboard: {
id: "dashboard",
meta: {
route: "/dashboard", // URL path - marks state as routable
},
},
}
// Parameters
meta: {
route: "/profile/:userId", // Required parameter
route: "/settings/:section?", // Optional parameter
}
// Inheritance
meta: {
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 context
meta: {
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.route property marks states as routable
  • play.route events 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.

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

Functions