@xmachines/play-actor
API / @xmachines/play-actor
The actor contract of the XMachines Play Architecture. It names no state machine library.
Installation
pnpm add @xmachines/play-actorPeer dependencies. Install them with the package:
pnpm add @xmachines/play-signalsOverview
@xmachines/play-actor gives you PlayActor, the contract that every layer above reads.
An actor of XMachines holds exactly two things:
| Member | Type | Description |
|---|---|---|
state | Signal.State<TSnapshot> | The reactive snapshot of the current state |
send | (event: TEvent) => void | The typed event dispatch |
It is an interface, and no base class. A state machine library is the concern of an
adapter, and @xmachines/play-xstate is the adapter of XState
v5. This package therefore names xstate nowhere.
The contract was an abstract CLASS that extended Actor of xstate, and the class carried
no implementation at all: both of its members were abstract. Its whole effect was to force
the inheritance of XState on every consumer of the contract, including
@xmachines/play-view and
@xmachines/play-router, which read currentView,
currentRoute and send, and no other member.
The capabilities are optional, and separate
A capability lives with the package that holds its shared machinery. Neither one implies the other, and neither one names an engine.
| Capability | Interface | Package | Adds |
|---|---|---|---|
| Routing | Routable | @xmachines/play-router | currentRoute, initialRoute |
| View | Viewable | @xmachines/play-view | currentView |
API Summary
PlayActor<TSnapshot, TEvent>
Both type parameters carry their real type through, and neither one erases it.
import type { PlayActor } from "@xmachines/play-actor";import { Signal } from "@xmachines/play-signals";
type Snapshot = { readonly value: string };type AuthEvent = { readonly type: "auth.login"; readonly username: string } | { readonly type: "auth.logout" };
class AuthActor implements PlayActor<Snapshot, AuthEvent> { readonly state = new Signal.State<Snapshot>({ value: "guest" });
send(event: AuthEvent): void { if (event.type === "auth.login") this.state.set({ value: "authenticated" }); }}
// The snapshot type reaches the reader, and it is no `unknown`:const actor: PlayActor<Snapshot, AuthEvent> = new AuthActor();const value: string = actor.state.get().value;A plain object satisfies the contract as well. Nothing here asks for a class.
const actor: PlayActor<Snapshot, AuthEvent> = { state: new Signal.State<Snapshot>({ value: "guest" }), send: () => {},};ActorEvent
The minimal shape of an event that an actor receives: { readonly type: string }. It is
the constraint of TEvent, and it is the base of PlayEvent of
@xmachines/play.
An actor with a capability
A concrete actor implements the contract and the capabilities that it needs. It extends the actor class of its own engine, and the contract stays out of the inheritance.
import type { PlayActor } from "@xmachines/play-actor";import type { Routable } from "@xmachines/play-router";import { Signal } from "@xmachines/play-signals";import { Actor, type AnyActorLogic } from "xstate";
class MyRoutableActor extends Actor<AnyActorLogic> implements PlayActor<unknown, { readonly type: string }>, Routable{ readonly state = new Signal.State<unknown>({}); readonly currentRoute = new Signal.Computed<string | null>(() => null); readonly initialRoute = "/";}@xmachines/play-xstate does exactly this, and it composes
each capability with compose(PlayerActor, withRouting, withView).
Requirements
- Node.js
>=24.0.0 - TypeScript
>=5.7(strict mode) - ESM only —
"type": "module"
@xmachines/play-actor - the actor contract of the Play Architecture
The contract is minimal, and it names no engine: state and send. A state machine
library is the concern of an adapter, and @xmachines/play-xstate is the adapter of
XState v5.
A capability is optional, and each one lives with the package that holds its shared machinery:
Routable, for an actor with a routing support, is in@xmachines/play-router, with the bridge that reads itViewable, for an actor with a view rendering, is in@xmachines/play-view, with the view store lifecycle, the context projection and the provider guards
A consumer that needs one capability installs one package, and a consumer that needs neither installs neither. Neither capability implies the other.