Skip to content

Architecture

XMachines Play implements the Universal Player Architecture — a layered, actor-authority design where XState machines are the single source of truth for routing, navigation, and view selection. Infrastructure (routers, renderers) is strictly passive: it observes actor atoms and reflects them, never enforcing guards or making decisions.

System Overview

XMachines JS is the JavaScript/TypeScript reference implementation of the Universal Player Architecture (Play RFC). It is a monorepo of modular packages that strictly separates business logic (the Actor) from infrastructure (router adapters and view renderers). An XState v5 state machine is the single source of truth; it owns all state, guards, and route validity. Infrastructure is passive: it observes atoms emitted by the Actor and proposes state changes via typed events. The Actor’s guards make every navigation and transition decision. Communication across the boundary is exclusively through atoms — never subscriptions, callbacks, or direct state mutation. Five architectural invariants (documented in the Play RFC) enforce this contract across the entire package graph.

Architectural Invariants

IDNameRule
INV-01Actor AuthorityInfrastructure proposes intents via play.route events; Actor decides via XState guards
INV-02Strict SeparationNo direct dependencies between Actor and Infrastructure layers. play-xstate never imports UI frameworks or routing libraries
INV-03Atom-Only ReactivityAll Actor→Infrastructure state changes flow through atoms (Atom, ReadonlyAtom). No subscriptions or event emitters
INV-04Passive InfrastructureInfrastructure observes Actor atoms, never controls state
INV-05State-Driven ResetInvalid external navigation is overwritten by Actor-derived state

Component Diagram

graph TD
    subgraph Foundation["Foundation"]
        core["@xmachines/core\n(BaseError, asDisposable, cache)"]
        shared["@xmachines/shared\n(tsconfig, oxlint, oxfmt)"]
    end

    subgraph Protocol["Protocol / Primitives"]
        play["@xmachines/play\n(PlayEvent, PlayError)"]
        atoms["@xmachines/play-atom\n(atoms, watchAtom)"]
    end

    subgraph ActorBase["Abstract Actor Base"]
        actor["@xmachines/play-actor\n(PlayActor)"]
    end

    subgraph ViewHalf["The View Half"]
        view["@xmachines/play-view\n(Viewable, PlaySpec, the view store lifecycle)"]
    end

    subgraph Concrete["Concrete Implementations"]
        xstate["@xmachines/play-xstate\n(PlayerActor, definePlayer, routing)"]
        router["@xmachines/play-router\n(RouterBridgeBase, RouteMap, Routable)"]
        routerxstate["@xmachines/play-router/xstate\n(extractMachineRoutes, createRouteMap)"]

        subgraph Views["View Renderers"]
            react["@xmachines/play-react"]
            vue["@xmachines/play-vue"]
            solid["@xmachines/play-solid"]
            svelte["@xmachines/play-svelte"]
            dom["@xmachines/play-dom"]
        end

        subgraph RouterAdapters["Router Adapters"]
            tsr["@xmachines/play-tanstack-react-router"]
            rr["@xmachines/play-react-router"]
            vr["@xmachines/play-vue-router"]
            sr["@xmachines/play-solid-router"]
            tss["@xmachines/play-tanstack-solid-router"]
            sk["@xmachines/play-sveltekit-router"]
            ssr["@xmachines/play-svelte-spa-router"]
            dr["@xmachines/play-dom-router"]
        end
    end

    core --> play
    atoms --> actor
    actor --> xstate
    actor --> router
    actor --> Views
    atoms --> xstate
    atoms --> router
    play --> xstate
    play --> router
    router --> RouterAdapters
    xstate -.->|"play.route events"| router

The diagram groups the packages by role. The build order is a separate view: each package sits one layer above its deepest workspace dependency, and Build Layers lists them.

Component Responsibilities

PackageResponsibilityKey File
@xmachines/playCore protocol types and error base classpackages/play/src/index.ts
@xmachines/play-atomThe atom primitives, isolated in one packagepackages/play-atom/src/index.ts
@xmachines/play-actorPlayActor, and nothing elsepackages/play-actor/src/play-actor.ts
@xmachines/play-viewViewable, PlaySpec, the shared view halfpackages/play-view/src/view-types.ts
@xmachines/play-xstateXState v5 adapter: definePlayer, PlayerActorpackages/play-xstate/src/player-actor.ts
@xmachines/play-patternThe route pattern language: one parse, and buildPathpackages/play-pattern/src/pattern-grammar.ts
@xmachines/play-urlThe URLPattern API, a base path, framework paramspackages/play-url/src/url-pattern-utils.ts
@xmachines/play-routerRouteMap, RouterBridgeBase, the provider lifecyclepackages/play-router/src/router-bridge-base.ts
@xmachines/play-router/xstateRoute extraction from an XState machinepackages/play-router/src/xstate/extract-routes.ts
@xmachines/play-reactReact renderer: ActorProvider, PlayRendererpackages/play-react/src/ActorProvider.tsx
@xmachines/play-vueVue 3 renderer: ActorProvider.vue, PlayRenderer.vuepackages/play-vue/src/ActorProvider.vue
@xmachines/play-solidSolidJS rendererpackages/play-solid/src/ActorProvider.tsx
@xmachines/play-svelteSvelte 5 rendererpackages/play-svelte/src/ActorProvider.svelte
@xmachines/play-domVanilla DOM rendererpackages/play-dom/src/create-play-ui.ts
@xmachines/play-tanstack-routerThe framework-free TanStack bridge basepackages/play-tanstack-router/src/tanstack-router-bridge-base.ts
@xmachines/play-tanstack-react-routerTanStack React Router bridgepackages/play-tanstack-react-router/src/tanstack-router-bridge.ts
@xmachines/play-tanstack-solid-routerTanStack SolidJS Router bridgepackages/play-tanstack-solid-router/src/solid-router-bridge.ts
@xmachines/play-vue-routerVue Router 4/5 bridgepackages/play-vue-router/src/vue-router-bridge.ts
@xmachines/play-react-routerReact Router 7/8 bridgepackages/play-react-router/src/react-router-bridge.ts
@xmachines/play-solid-routerSolidJS Router bridgepackages/play-solid-router/src/solid-router-bridge.ts
@xmachines/play-dom-routerVanilla DOM hash/history router bridgepackages/play-dom-router/src/dom-router-bridge.ts
@xmachines/play-sveltekit-routerSvelteKit router bridgepackages/play-sveltekit-router/src/sveltekit-router-bridge.ts
@xmachines/play-svelte-spa-routerSvelte SPA Router bridgepackages/play-svelte-spa-router/src/svelte-spa-router-bridge.ts
@xmachines/sharedShared configs: tsconfig, oxlint, oxfmt, vitestpackages/shared/config/
@xmachines/coreBaseError, the release protocol, a cache, helperspackages/core/src/
@xmachines/docsAPI docs and RFC documentationpackages/docs/

Data Flow

Actor State Change → UI Render

flowchart TD
    A["XState machine transition"]
    B["PlayerActor observes its own transitions"]
    C["actor.state atom updated — the ONE write of the transition"]
    D["actor.currentRoute computed atom recomputes\nderives URL from meta.route + context.params"]
    E["actor.currentView computed atom recomputes\nderives the PlaySpec from meta.view"]
    F["Framework atom subscription fires\nmicrotask"]
    G["ActorProvider reads actor.currentView.get()"]
    H["PlayRenderer renders via @xmachines/json-render-* Renderer"]

    A --> B --> C
    C --> D
    C --> E
    D --> F
    E --> F
    F --> G --> H

User Navigation (Browser URL → Actor)

flowchart TD
    A["Browser popstate / router navigation event"]
    B["Router adapter watchRouterChanges() handler fires"]
    C["RouterBridgeBase.syncActorFromRouter(pathname, search)"]
    D["sanitizePathname(pathname)\nlength/content guard"]
    E["buildPlayRouteEvent(path, routeMap)\nresolves stateId from URL via URLPattern"]
    F["actor.send({ type: 'play.route', to: '#stateId', params, query })"]
    G["XState machine guard evaluates transition"]
    H["machine transitions → atoms update\n→ URL bar updated"]
    I["guard blocks → machine stays\n→ router re-syncs to actor's current route"]

    A --> B --> C --> D --> E --> F --> G
    G -->|valid| H
    G -->|invalid| I

Actor State Change → Router URL Sync

flowchart TD
    A["actor.currentRoute computed atom changes"]
    B["RouterBridgeBase routeWatcher\natom subscription fires"]
    C["watchAtom microtask queued\nscheduled dedup guard"]
    D["syncRouterFromActor(route) called"]
    E["lastSyncedPath set\necho suppression — prevents circular callback"]
    F["navigateRouter(path)\ncalls framework router's navigate API"]
    G["Router updates URL bar"]

    A --> B --> C --> D --> E --> F --> G
flowchart TD
    A["bridge.connect()"]
    B["routeWatcher armed on actor.currentRoute"]
    C["watchRouterChanges() subscribed"]
    D["getInitialRouterPath() reads current browser URL"]
    E["resolves sanitized URL path → stateId via routeMap"]
    F{"Compare stateId vs actor.initialRoute"}
    G["syncActorFromRouter()\nrouter wins — deep-link"]
    H["navigateRouter()\nactor wins — restore"]

    A --> B --> C --> D --> E --> F
    F -->|"URL ≠ machine initial state"| G
    F -->|"URL = machine initial state"| H

View derivation

flowchart TD
    A["XState snapshot"]
    O{"a state node of the machine\ndeclares meta.view.outlets?\n(cached for each machine)"}
    T["resolveViewTree(root, snapshot.value)\na view with outlets composes its active children;\nany other node keeps its deepest view"]
    G["composeViews(tree) in play-view\ncached for each tree: elements, authored state, viewParts\nviewKey = the id of the outermost composing state"]
    B["resolveViewMeta(active branch: the routed region first)\nfinds deepest meta.view with root + elements\n(its record key becomes viewKey)"]
    C["snapshot.context\nprojected wholesale as the /context slice"]
    D["composePlayState(spec.state, slice)\nstate: { ...authored, context: slice }"]
    E["reuseComposedState (in the currentView atom)\ncarry previous state reference when the slice is value-unchanged"]
    F["Derived PlaySpec (with viewKey) is the value of the currentView atom"]

    A --> O
    O -->|"yes"| T
    T -->|"an active view composes"| G --> C
    T -->|"no active view composes:\nthe view that the walk resolved"| C
    O -->|"no"| B --> C
    C --> D --> E --> F

The composition keeps each element of the outer view by reference, except an element that receives a child view. It prefixes each key of a child view with the id of its state. composeViews caches the result for each tree of view objects. A context assign therefore emits the same elements and viewParts references, and the emit gate of withView walks no element. When the viewKey stays and the viewParts value changes, createViewStoreLifecycle writes the authored keys that the store does not hold. play-dom then gives the new spec to setSpec of the incremental renderer, which keeps each node whose element did not change.

State atoms on the actor:

AtomTypeDescription
actor.stateAtom<AnyMachineSnapshot>XState snapshot; the ONE write of a transition, on active states only
actor.currentRouteReadonlyAtom<string | null>Derived from meta.route + context.params; null when unroutable. It carries compare: () => false, so EVERY transition notifies — a refused guard derives the same path, and the bridge must still hear it
actor.currentViewReadonlyAtom<PlaySpec | null>Derived from meta.view; the previous value gates an emission, so an unchanged view keeps its reference

Key Abstractions

PlayEvent<TPayload>

The minimal event contract for Actor communication: any object with a readonly type: string property. TPayload extends Record<string, unknown> for additional fields. Framework-agnostic; used directly by XState, router adapters, and domain logic. Part of @xmachines/play.

import type { PlayEvent } from "@xmachines/play";
type LoginEvent = PlayEvent<{ userId: string; timestamp: number }>;

PlayError

Base class for the typed runtime errors of every Play package. It extends BaseError of @xmachines/core/errors. Carries scope (throwing class/module) and code (stable machine-readable identifier, e.g. "PLAY_ROUTER_SYNC_FAILED"). Subclassed per package, exported from each package’s ./errors subpath. Never match on .message — always match on .code or the subclass.

import { PlayError } from "@xmachines/play/errors";
import { RouterSyncError } from "@xmachines/play-router/errors";
if (err instanceof RouterSyncError) {
/* err.scope, err.code, err.cause */
}

PlayActor<TSnapshot, TEvent>

The actor contract, as an INTERFACE. It asks for two members: a reactive state: Atom<TSnapshot>, and a typed send(event: TEvent): void. It names no state machine library, so a consumer of the contract installs xstate never. An implementation satisfies it (e.g. PlayerActor).

Routable

Optional capability interface. Exposes currentRoute: ReadonlyAtom<string | null> (derived URL path) and readonly initialRoute: string | null (machine’s initial route, fixed at definition time). Router bridges consume this interface directly — they never depend on the concrete PlayerActor.

Viewable

Optional capability interface. Exposes currentView: ReadonlyAtom<PlaySpec | null>. View renderers (PlayRenderer) consume this contract to resolve the current view description into UI without coupling to the framework or actor implementation.

PlaySpec

Extends @xmachines/json-render-core Spec. deriveCurrentView projects the machine’s whole context into the derived spec’s state under the read-only /context subtree, and stamps a viewKey from the selected meta entry so providers can reseed vs refresh their store. An authored outlets field composes the views of the active substates into the view of their parent, and the composed spec also carries viewParts. Specs read context through the ordinary { $state: "/context/…" } grammar; element props are never enriched. typedSpec() type-checks the spec literal at the definition site (XState’s meta is untyped).

PlayerActor<TMachine>

Concrete XState v5 actor. It extends XState’s own Actor directly, and it implements PlayActor. It receives the machine in its constructor, so an instance IS the actor rather than a wrapper around one. Bridges its own subscription to atoms. Exposes: state, send(), start(), stop(), can().

Routable and Viewable are OPTIONAL capabilities, and the class implements neither. Each one is a mixin behind its own entry point — @xmachines/play-xstate/routing and @xmachines/play-xstate/view — and compose builds the class that an application needs:

definePlayer({ machine, actor: compose(PlayerActor, withRouting, withView) });

The core imports neither capability, so an application that declares no route loads no routing code, and one that renders no view loads no view code and installs no json-render.

The composition order decides nothing. PlayerActor writes ONE atom for each transition — state — and every capability that publishes a value DERIVES it with a computed atom over that one. The engine evaluates currentRoute and currentView from a single write, in topological order, so a router bridge sees a guard redirect and a renderer sees the view of the same snapshot whatever order compose applied.

A capability that must run an EFFECT on a transition overrides onSnapshot, and the override calls super.onSnapshot() first.

definePlayer(config) → PlayerFactory

Factory creator. Each call constructs one PlayerActor, which is itself the XState actor and derives its own initialRoute from its pre-start snapshot. Restoring a snapshot is the exception: the default initial route can only come from XState’s pure initialTransition helper, whose inert actor scope constructs a throwaway actor and evaluates the initial transition twice. Returns (input?, restore?) => PlayerActor<TMachine>.

import { definePlayer } from "@xmachines/play-xstate";
const createPlayer = definePlayer({ machine, options });
const actor = createPlayer(); // or createPlayer(input) or createPlayer(undefined, { snapshot })
actor.start();

RouterBridgeBase

Abstract base class capturing all common router bridge logic. Template Method pattern — subclasses implement exactly three abstract methods: navigateRouter(path), watchRouterChanges(), unwatchRouterChanges(). Manages isConnected, lastSyncedPath (echo suppression), isProcessingNavigation (re-entrant guard redirect prevention), and routeWatcher lifecycle. Enforces one-bridge-per-actor via a module-level WeakMap.

class MyRouterBridge extends RouterBridgeBase {
protected navigateRouter(path: string): void {
/* push URL */
}
protected watchRouterChanges(): void {
/* subscribe to router */
}
protected unwatchRouterChanges(): void {
/* unsubscribe */
}
}

RouteMap / createRouteMap

Bidirectional stateId ↔ URL path lookup. createRouteMap(machine) of @xmachines/play-router/xstate → RouteMap. createRouteMapFromTree(routeTree) of @xmachines/play-router takes the output of extractMachineRoutes(machine) directly. Both drop the synthetic root node of the tree, so neither map answers a state ID that the machine does not hold. The match of a parameterized route, for example /profile/:userId, uses URLPattern of @xmachines/play-url.

PlayRouteEvent

Unified routing event sent by router adapters to the Actor.

interface PlayRouteEvent {
readonly type: "play.route";
readonly to: string; // e.g. "#home", "#profile"
readonly params?: Record<string, string>; // URL path params, e.g. { userId: "123" }
readonly query?: Record<string, string>; // Query string params
readonly match?: unknown; // URLPatternResult (optional, for debugging)
}

watchAtom(atom, callback)

Subscribe to a single atom with microtask coalescing and memory-safe cleanup. It builds one subscribe for the life of the watch, so no code arms anything again. The disposed flag guards post-cleanup callbacks, and the scheduled guard deduplicates rapid synchronous atom changes. Returns a plain release function.

formatPlayRouteTransitions(machineConfig)

Crawls machine state configs looking for states with meta.route and auto-generates play.route transition handlers at the root machine level. Each generated transition targets the matching state, guards on event.to === "#stateId", and assigns event.params and event.query to context. Returns the same config type T — directly usable by setup().createMachine().

View Renderer Pattern

All five view renderer packages follow an identical structural pattern:

ComponentRole
ActorProviderSubscribes to the actor.currentView atom; owns the atom lifecycle; provides view context
PlayUIProviderConvenience wrapper combining ActorProvider + framework’s JSONUIProvider
PlayRendererZero-prop leaf component; reads from context; delegates to @xmachines/json-render-* Renderer
useActor()Hook/composable for accessing the raw actor in consuming components

Framework-specific atom bridging:

PackageAtom → Render mechanism
@xmachines/play-reactuseAtomEffect → useReducer force-update
@xmachines/play-vueVue reactivity bridge via watchEffect equivalent
@xmachines/play-solidSolidJS native reactive integration
@xmachines/play-svelteSvelte 5 runes ($effect) via actor-context.svelte.ts
@xmachines/play-domManual DOM updates via createPlayUI / createRenderer / PlayRenderer

Per-view component state is managed by @xstate/store: a fresh store atom is created per PlaySpec transition (uncontrolled mode), or an external store prop is passed via ActorProvider/PlayUIProvider (controlled mode).

Router Adapter Pattern

All eight router adapter packages follow the Template Method pattern via RouterBridgeBase. Each adapter implements exactly three abstract methods:

// How to tell the framework router to change URL
protected abstract navigateRouter(path: string): void;
// How to subscribe to router location change events
protected abstract watchRouterChanges(): void;
// How to unsubscribe from router location change events
protected abstract unwatchRouterChanges(): void;
PackageBridge class
@xmachines/play-tanstack-react-routerTanStackReactRouterBridge
@xmachines/play-react-routerReactRouterBridge
@xmachines/play-vue-routerVueRouterBridge
@xmachines/play-solid-routerSolidRouterBridge
@xmachines/play-tanstack-solid-routerTanStackSolidRouterBridge
@xmachines/play-sveltekit-routerSvelteKitRouterBridge
@xmachines/play-svelte-spa-routerSvelteSpaRouterBridge
@xmachines/play-dom-routerDomRouterBridge

Each adapter also exports a framework-integrated provider component (e.g. PlayRouterProvider) and a createRouteMapFrom* factory appropriate for its router’s route definition format.

Directory Structure

packages/
├── shared/ # Zero-dependency shared configs (tsconfig, oxlint, oxfmt, vitest)
├── core/ # Layer 0: BaseError, the release protocol, the cache, helpers; no module state
├── play/ # Layer 1: Core protocol: PlayEvent, PlayError, assertNonNullable
├── play-atom/ # Layer 0: the atom primitives — all atom imports go here
│ └── src/ # Declares the atom contract, binds the engine; watchAtom utility
├── play-actor/ # Layer 1: the PlayActor contract — state and send, and nothing else
├── play-pattern/ # Layer 1: the route pattern language — one parse, and the path it builds back
├── play-url/ # Layer 2: the URL layer — the URLPattern API, a base path, framework params
├── play-view/ # Layer 2: Viewable, PlaySpec, the view store lifecycle, the context projection
│
├── play-xstate/ # Layer 4: Concrete XState v5 actor: definePlayer, PlayerActor
│ └── src/
│ ├── player-actor.ts # PlayerActor — concrete actor
│ ├── define-player.ts # definePlayer factory
│ ├── routing/ # deriveRoute, buildRouteUrl, formatPlayRouteTransitions
│ └── view/ # deriveCurrentView (meta.view → PlaySpec)
│
├── play-router/ # Layer 3: Route extraction, bidirectional mapping, bridge base
│ └── src/
│ ├── router-bridge-base.ts # RouterBridgeBase (Template Method for all adapters)
│ ├── base-route-map.ts # RouteMap (bidirectional stateId ↔ path)
│ ├── xstate/ # the machine half — extractMachineRoutes, createRouteMap
│ ├── router-sync.ts # buildPlayRouteEvent, extractRouteParams, sanitizePathname
│ └── types.ts # PlayRouteEvent, RouterBridge, RouteTree, WindowLike...
│
├── play-react/ # View renderer: React (ActorProvider, PlayRenderer)
├── play-vue/ # View renderer: Vue 3
├── play-solid/ # View renderer: SolidJS
├── play-svelte/ # View renderer: Svelte 5
├── play-dom/ # View renderer: Vanilla DOM
│
├── play-tanstack-react-router/ # Router adapter: TanStack Router (React)
├── play-tanstack-solid-router/ # Router adapter: TanStack Router (SolidJS)
├── play-react-router/ # Router adapter: React Router 7/8
├── play-vue-router/ # Router adapter: Vue Router 4/5
├── play-solid-router/ # Router adapter: SolidJS Router
├── play-svelte-spa-router/ # Router adapter: Svelte SPA Router
├── play-sveltekit-router/ # Router adapter: SvelteKit
├── play-dom-router/ # Router adapter: Vanilla DOM (History API)
│
└── docs/ # @xmachines/docs — API docs (auto-generated) + RFC specifications
└── rfc/ # Living RFC documents (play.md, streams.md, etc.)

Each package follows the same internal structure:

packages/<name>/
├── src/
│ ├── index.ts # Single public barrel — only file consumers import
│ └── *.ts / *.tsx # Implementation files (kebab-case)
├── test/ # Test files (*.spec.ts, *.test.ts)
├── dist/ # Build output (gitignored)
├── examples/ # Runnable demo apps (present in some packages)
├── package.json # "type": "module"; exports only via "./dist/index.js"
└── tsconfig.json # Extends @xmachines/shared/tsconfig; composite: true

TypeScript Build Graph

The monorepo uses TypeScript composite project references ("composite": true per package) for a correct, incremental, dependency-ordered build. The root tsconfig.json lists every package in layer order — leaves first, dependents last — and it is the ONLY build order of the workspace. tsc --build at the root reads that array and resolves the graph.

The layer table and the diagram live in development.md.

Two facts about the graph belong to this document, because they are architecture and not build order:

  • The routers read NO view package. play-router and the eight adapters name play-view and the five renderers in no manifest. A consumer that routes and renders nothing of its own therefore installs no json-render.
  • The renderers read no router. The two halves meet in the application alone, which is INV-02.

With declarationMap: true in the base tsconfig (from @xmachines/shared/tsconfig), IDE “Go to Definition” jumps to TypeScript source files rather than compiled .d.ts files, and refactoring works correctly across package boundaries without requiring a build step.

Dependency rules:

The protocol layer is play, play-atom and play-actor. play reads core alone, for the base of PlayError. play-atom reads no workspace package. play-actor reads play-atom, and no other package of the workspace.

LayerMay import fromMust not import from
Core (core)No packageAny @xmachines/* package
Protocol (play)coreAny other @xmachines/* package
Protocol (play-atom)@xstate/storeAny other @xmachines/* package
Protocol (play-actor)play-atomAny other @xmachines/* package
Pattern language (play-pattern)coreAny other @xmachines/* package
URL layer (play-url)Protocol layer, play-pattern, urlpattern-polyfillAny other @xmachines/* package
View half (play-view)Protocol layer, @xmachines/json-render-coreRouter adapters, framework code
Actor logic (play-xstate)Protocol layer, play-pattern, xstateView renderers, router adapters
Router infrastructure (play-router)Protocol layer, play-url, @statelyai/graphView renderers
Router adapters (play-*-router)play-router, protocol layer, framework router libView renderers
View renderers (play-*)Protocol layer, play-view, @xmachines/json-render-*Router adapters

Every layer above the protocol layer may also import @xmachines/core, and play imports it for BaseError. It is a peer, and not a dependency: err instanceof BaseError answers false for an error of a second copy, so an install must hold one copy. See the packages that must exist one time.

play-view and play-router are OPTIONAL peer dependencies of play-xstate, because the two capability subpaths carry them: @xmachines/play-xstate/view names the first and @xmachines/play-xstate/routing names the second. The core entry point imports neither.

Error Handling

All @xmachines/* errors extend BaseError of @xmachines/core/errors, and carry two stable fields:

  • scope — the class or module that threw (e.g. "RouterBridgeBase")
  • code — a stable machine-readable identifier (e.g. "PLAY_ROUTER_SYNC_FAILED")

Every error of a Play package extends PlayError, which extends BaseError. A package below the Play protocol extends BaseError directly: @xmachines/core throws DisposeConflictError when asDisposable receives a named release that the scope would never run.

Error hierarchy:

flowchart TD
    BE["BaseError\n@xmachines/core/errors"]
    BE --> DCE["DisposeConflictError\nCORE_DISPOSE_CONFLICT"]
    BE --> PE["PlayError\n@xmachines/play/errors"]
    PE --> NNE["NonNullableError\nPLAY_NON_NULLABLE"]
    PE --> RSE["RouterSyncError\nPLAY_ROUTER_SYNC_FAILED"]
    PE --> DBE["DuplicateBridgeError\nPLAY_ROUTER_DUPLICATE_BRIDGE"]
    PE --> IEE["InvalidEventError\nPLAY_XSTATE_INVALID_EVENT"]
    PE --> IME["InvalidMachineError\nPLAY_XSTATE_INVALID_MACHINE"]
    PE --> MRP["MissingRouteParamError\nPLAY_XSTATE_ROUTE_PARAM_MISSING"]
    PE --> IRP["InvalidRouteParamError\nPLAY_XSTATE_ROUTE_PARAM_INVALID"]
    PE --> MSI["MissingStateIdError\nPLAY_XSTATE_MISSING_STATE_ID"]
    PE --> IRE["InvalidRouteMetadataError\nPLAY_XSTATE_INVALID_ROUTE_METADATA"]
    PE --> ATN["ActorThrewNonErrorError\nPLAY_XSTATE_NON_ERROR_THROWN"]

Package-specific errors are exported from ./errors subpath imports:

import { PlayError } from "@xmachines/play/errors";
import { RouterSyncError } from "@xmachines/play-router/errors";
import { InvalidEventError } from "@xmachines/play-xstate/errors";

Error behavior by case:

ErrorPackageBehavior
MissingRouteParamErrorplay-xstateTransient — currentRoute returns null; does not throw
InvalidRouteParamErrorplay-xstatePermanent — deriveCurrentRoute passes it on: currentRoute reports it through onError and keeps its last route, and initialRoute throws from the constructor
ActorThrewNonErrorErrorplay-xstateConstructed for onError when the machine throws a non-Error; original value on cause
RouterSyncErrorplay-routerWraps router sync failures
DuplicateBridgeErrorplay-routerTwo bridges registered for the same actor
View errorsplay-xstateCaught in the currentView atom; forwarded to onError hook; last valid view retained

Application Bootstrap (React example)

import { setup } from "xstate";
import { definePlayer, compose, PlayerActor } from "@xmachines/play-xstate";
import { formatPlayRouteTransitions, withRouting } from "@xmachines/play-xstate/routing";
import { withView } from "@xmachines/play-xstate/view";
import { extractMachineRoutes } from "@xmachines/play-router/xstate";
import { createRouteMapFromTree, PlayRouterProvider } from "@xmachines/play-tanstack-react-router";
import { PlayUIProvider, PlayRenderer, defineRegistry } from "@xmachines/play-react";
// 1. Define machine — meta.route declares virtual routes; meta.view declares UI specs
const machineConfig = formatPlayRouteTransitions({
id: "app",
initial: "home",
context: { params: {}, query: {} },
states: {
home: {
id: "home",
meta: {
route: "/",
view: { root: "home", elements: { home: { type: "HomePage", props: {}, children: [] } } }
}
},
profile: {
id: "profile",
meta: {
route: "/profile/:userId",
view: { root: "profile", elements: { profile: { type: "ProfilePage", props: { userId: undefined }, children: [] } } }
}
},
},
});
const machine = setup({}).createMachine(machineConfig);
// 2. Build route map from machine definition (done once at startup)
const routeTree = extractMachineRoutes(machine);
const routeMap = createRouteMapFromTree(routeTree);
// 3. Create player factory and start actor — the providers need BOTH capabilities
const createPlayer = definePlayer({ machine, actor: compose(PlayerActor, withRouting, withView) });
const actor = createPlayer();
actor.start();
// 4. Define component registry for json-render
const registryResult = defineRegistry({ /* components */ });
// 5. Render — framework providers wire actor → router → view
function App() {
return (
<PlayRouterProvider actor={actor} router={router} routeMap={routeMap}>
<PlayUIProvider actor={actor} registryResult={registryResult}>
<PlayRenderer />
</PlayUIProvider>
</PlayRouterProvider>
);
}

Anti-Patterns

Importing @xstate/store directly

// ❌ Wrong — bypasses the atom isolation layer, and names the engine in your own .d.ts
import { createAtom } from "@xstate/store";
// ✅ Correct — all atom imports go through play-atom
import { createAtom } from "@xmachines/play-atom";

Using framework state for business logic

// ❌ Wrong — violates INV-03 (Atom-Only Reactivity) and INV-04 (Passive Infrastructure)
const [currentUser, setCurrentUser] = useState(null);
// ✅ Correct — observe the actor atom directly
const currentUser = useActorSelector(actor, (s) => s.context.currentUser);

Calling navigateRouter inside watchRouterChanges

Calling navigateRouter() before updating lastSyncedPath inside the watchRouterChanges callback causes an echo loop. The router navigation fires the watcher again, which sends a duplicate play.route event. Always update lastSyncedPath before calling navigateRouter() — see RouterBridgeBase source at packages/play-router/src/router-bridge-base.ts.

Skipping sanitizePathname in custom bridge implementations

Custom watchRouterChanges() implementations that bypass syncActorFromRouter() and process pathnames directly skip the length/content guards that protect the route-map lookup from malformed or oversized paths. Always call sanitizePathname(path) before processing.

Cross-Cutting Concerns

Circular update prevention: lastSyncedPath in RouterBridgeBase provides echo suppression for the actor→router direction; isProcessingNavigation flag guards against re-entrant guard-redirect loops in syncActorFromRouter. scheduled in watchAtom deduplicates rapid synchronous atom changes via microtask coalescing.

SSR / test injection: WindowLike and LocationLike structural interfaces in @xmachines/play-router allow injecting mock objects in router adapters, enabling testing without a real browser environment.

Logging: No structured logging framework. PlayerOptions exposes lifecycle hooks — onStart, onStop, onTransition, onStateChange, onError — as observability extension points. A renderer writes to console.error in ONE case: it contained a failure, and no handler of the host receives that failure. Such a failure is the one result that nobody can debug. Give onError to send the failure of a view to your own observability tool instead.

Five paths reach that case. The host gives no onError prop. The handler of the host throws, or the producer of the fallback throws, or a release of the caller throws — each of those reports the failure of the HOST, which no handler of the host receives. ActorProvider of play-vue stops a second report of one error, and onError stays silent there on purpose, because the four other renderers see such an error never.

Three other places write to console.warn, and each one reports a configuration that the library cannot refuse: warnOnce of @xmachines/play-view names a /context projection that it cannot derive, one time for each spec; @xmachines/play-xstate names a meta.view.contextProps field, which the view does not read, one time for each spec; and @xmachines/play-router names a basePath that a bridge with no mount ignores. A warning is the answer where a throw would stop an application for a fault that it can still run with. No other library source calls console.*.

Authentication: Not in scope. Enforced via XState guards on play.route transitions. The guard evaluates machine context (e.g., isLoggedIn) before permitting navigation. No library-level auth primitives — the machine defines all access control.

Atom isolation: All atom imports go through @xmachines/play-atom, which re-exports the atom of @xstate/store. This isolates the codebase from churn in that library — a version upgrade or API change requires editing only one package.

Global state: An activeBridges WeakMap at module level in packages/play-router/src/router-bridge-base.ts prevents duplicate bridges per actor. This is the only module-level mutable state in the library.

Threading: Single-threaded event loop. No worker threads. XState actor subscription callbacks fire synchronously in send(). Atom updates are synchronous to ensure router bridges see guard redirects immediately.


See Play RFC for the authoritative protocol specification.