@xmachines/play-actor
v2.1.1
Published
Abstract Actor base class for XMachines Play Architecture
Maintainers
Readme
@xmachines/play-actor
Abstract Actor base class for XMachines Play Architecture.
Installation
pnpm add @xmachines/play-actorPeer dependencies. Install them with the package:
pnpm add xstate @xmachines/play @xmachines/play-signals @xmachines/json-render-coreOverview
@xmachines/play-actor gives you AbstractActor, a minimal base class. The class extends the XState Actor class, and it enforces the signal protocol of the Play Architecture (RFC section 5.3). It exposes reactive TC39 Signals for the infrastructure layer. It also keeps the complete compatibility with the XState ecosystem, which includes the devtools and the inspection.
The core protocol is small on purpose:
| Property | Type | Description |
| -------- | ------------------------- | ---------------------------------------- |
| state | Signal.State<unknown> | Reactive snapshot of current actor state |
| send | (event: TEvent) => void | Event dispatch method |
Separate interfaces declare the optional capabilities. A concrete actor implements only the interfaces that it needs:
| Interface | Property | Description |
| ---------- | ----------------------------------------------- | ------------------------------------- |
| Routable | currentRoute: Signal.Computed<string \| null> | Current route path derived from state |
| Routable | initialRoute: string \| null | The route where the actor starts |
| Viewable | currentView: Signal.State<PlaySpec \| null> | Current JSON-render view spec |
An adapter, such as @xmachines/play-xstate, makes the concrete implementations.
API Summary
AbstractActor<TLogic, TEvent>
The abstract base class extends the XState Actor<TLogic> class.
A subclass is the actor. Give the logic and its options to super(), so that one
instance holds the running machine. Reach the send method of XState through the
prototype. This class declares send as abstract for one reason only: to narrow the
event type. TypeScript forbids a super call to an abstract member.
import { AbstractActor } from "@xmachines/play-actor";
import { Signal } from "@xmachines/play-signals";
import { Actor, type ActorOptions, type AnyActorLogic } from "xstate";
class MyActor extends AbstractActor<AnyActorLogic> {
// Required: reactive state signal
state: Signal.State<unknown>;
constructor(logic: AnyActorLogic, options?: ActorOptions<AnyActorLogic>) {
super(logic, options);
this.state = new Signal.State(this.getSnapshot());
super.subscribe((snapshot) => this.state.set(snapshot));
}
// Required: typed event dispatch
override send(event: { type: string }): void {
Actor.prototype.send.call(this, event);
}
}With a typed event union:
// imports as in the previous example
type AuthEvent = { type: "auth.login"; username: string } | { type: "auth.logout" };
class AuthActor extends AbstractActor<AnyActorLogic, AuthEvent> {
state = new Signal.State({ isAuthenticated: false, username: null });
override send(event: AuthEvent): void {
Actor.prototype.send.call(this, event);
}
}typedSpec(spec)
This identity helper gives a view-spec literal the type PlaySpec at the definition site. The
XState meta field has the type Record<string, unknown>. Therefore this helper is the place
where the spec shape receives the compile-time check and the IDE autocomplete. The helper has no
cost at run time.
import { typedSpec } from "@xmachines/play-actor";
// In an XState machine meta block:
meta: {
view: typedSpec({
root: "root",
elements: {
root: {
type: "Dashboard",
props: { username: { $state: "/context/username" } },
children: [],
},
},
}),
}PlaySpec
This type extends the Spec type of @xmachines/json-render-core. Each derived view receives
the complete machine context in its state store, under the read-only /context subtree. A
spec therefore reads the context through the ordinary { $state: "/context/…" } grammar: in a
prop, in a visible condition, and in repeat.statePath.
/context is read-only by design. Nothing can write to it. The machine context changes through
an event only. A $bindState write or a setState write under /context throws an error, and
the error names the event to send. This is the model: the bindable ephemeral state is at the
root of the store, from spec.state; the domain state is in the machine, and it changes through
events that are meaningful and easy to inspect.
The path shows the origin of each value. /context/params/username comes from the URL.
/context/username belongs to the machine. One value can never hide the other.
The model projects everything, and this has two consequences. The first consequence is exposure. The complete context is visible to the client in the view store, which includes a debug panel, an inspector, and a validator. The context is a client-side value in each case, so keep a secret out of it.
The second consequence is emission granularity. The emit gate compares the context field by
field, at the top level only. Therefore an event that changes any field emits the view again
with the same viewKey. A provider refreshes /context in the live store, and it does not seed
the store again. The component does not remount, and the ephemeral view state and the focus
stay. However, a new emission is still a render pass in the framework layer. Keep
high-frequency ephemeral data, such as a draft for each keystroke or a timer, in the view store
(spec.state with $bindState) or in a child actor. The domain state belongs in the context. A
keystroke does not.
import type { PlaySpec } from "@xmachines/play-actor";
const spec: PlaySpec = {
root: "root",
elements: {
root: {
type: "Profile",
props: { username: { $state: "/context/username" } },
children: [],
},
},
};Historical note: an earlier version had a
contextPropsfield. At first the field drove an implicit prop-enrichment pass. That pass merged the allowlisted context fields and the URL params into the props of every element. We removed it, because it put values into components that never asked for them, and it let URL data from the user hide machine-owned state. The field was then a projection filter for a short time. We removed that filter too, because a limit on what a view can read added machinery without a real problem to solve. Always validate the derived view (actor.currentView.get()), not the rawmeta.view. Thestateof the derived spec carries the projection, so a tool such asvalidateSpecsees a spec that is consistent with itself.
Routable
Interface for actors that support routing.
import { AbstractActor, type Routable } from "@xmachines/play-actor";
import { Signal } from "@xmachines/play-signals";
import type { AnyActorLogic, EventObject } from "xstate";
// Implement in a concrete actor (note: RoutableActor interface is exported from @xmachines/play-router):
class MyRoutableActor extends AbstractActor<AnyActorLogic> implements Routable {
state = new Signal.State<{ path?: string }>({});
currentRoute = new Signal.Computed<string | null>(() => this.state.get().path ?? null);
initialRoute = "/";
override send(event: EventObject): void {
/* dispatch */
}
}Viewable
Interface for actors that expose a renderable view signal.
import type { Viewable } from "@xmachines/play-actor";
import type { PlaySpec } from "@xmachines/play-actor";
import { Signal } from "@xmachines/play-signals";
// currentView carries PlaySpec | null
const signal = new Signal.State<PlaySpec | null>(null);
const viewable: Viewable = { currentView: signal };BaseActorProviderProps<TRegistry>
The framework-agnostic base props. Every ActorProvider implementation shares them: React, Vue, Solid, and Svelte. Each framework renderer package extends this interface.
import type { BaseActorProviderProps } from "@xmachines/play-actor";
import type { DefineRegistryResult } from "@xmachines/json-render-react";
interface ActorProviderProps extends BaseActorProviderProps<DefineRegistryResult> {
fallback?: React.ReactNode;
children: React.ReactNode;
}BaseViewContextValue<TRegistry>
The framework-agnostic base of the ViewContextValue type in each framework. It holds the spec, handlers, registry, and store fields. These fields are identical in React, Vue, Solid, and Svelte.
Testing
Run the test suite for this package in isolation:
# From the package directory
pnpm test
# From the monorepo root (workspace-scoped)
pnpm --filter @xmachines/play-actor test
# Watch mode
pnpm --filter @xmachines/play-actor run test:watchRequirements
- Node.js
>=22.0.0 - TypeScript
>=5.7(strict mode) - ESM only —
"type": "module"
