Skip to content

@xmachines/play-actor

API / @xmachines/play-actor

Abstract Actor base class for XMachines Play Architecture.

License: MIT Version

Installation

Terminal window
pnpm add @xmachines/play-actor

Peer dependencies. Install them with the package:

Terminal window
pnpm add xstate @xmachines/play @xmachines/play-signals @xmachines/json-render-core

Overview

@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:

PropertyTypeDescription
stateSignal.State<unknown>Reactive snapshot of current actor state
send(event: TEvent) => voidEvent dispatch method

Separate interfaces declare the optional capabilities. A concrete actor implements only the interfaces that it needs:

InterfacePropertyDescription
RoutablecurrentRoute: Signal.Computed<string | null>Current route path derived from state
RoutableinitialRoute: string | nullThe route where the actor starts
ViewablecurrentView: 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 contextProps field. 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 raw meta.view. The state of the derived spec carries the projection, so a tool such as validateSpec sees 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:

Terminal window
# 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:watch

Requirements

  • Node.js >=22.0.0
  • TypeScript >=5.7 (strict mode)
  • ESM only"type": "module"

@xmachines/play-actor - the abstract Actor base class of the Play Architecture

This package gives you AbstractActor, a minimal base class. It extends the XState Actor class, and it enforces the signal protocol of the Play Architecture (RFC section 5.3).

The core protocol is minimal: state and send. Two interfaces give the optional capabilities:

  • Routable: for an actor with a routing support
  • Viewable: for an actor with a view rendering

The class keeps the compatibility with the XState ecosystem, such as the inspection and the devtools. It also exposes the reactive signals of the communication with the infrastructure layer.

See

Play RFC

Classes

Interfaces

Variables

Functions