npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xmachines/play-xstate

v6.1.0

Published

XState v5 adapter for Play Architecture

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.

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

pnpm add @xmachines/play-xstate @xmachines/core @xmachines/play @xmachines/play-atom xstate

Peer dependencies. Install them with the package:

  • @xmachines/core — the base package. This package reads the helpers of its utils subpath.
  • @xmachines/play — the core protocol. This package reads the error classes from its errors subpath.
  • @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:

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

| 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/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.

| 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.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

| 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 too

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 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 reenter flag re-enters the domain, and not the target. The target state runs its own exit and entry actions on each navigation to it, under both values. A state that declares reenter: true also re-enters the domain of its transition: the root under handler: "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 under reenter: 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.