Skip to content

Inspecting a Running Actor

A PlayerActor is an XState actor, so every XState inspection tool works on it unchanged. This guide covers how to attach an inspector, what the events look like once they arrive, and the two things that are specific to XMachines: the actor is the actor an inspector sees, and the observer is configured on the factory rather than on each instance.

After reading this you will be able to open the Stately inspector against a running demo or your own app, inspect an actor that has no browser around it, and wire an inspector that can be turned on after the actor has already started.


Quick start

Install the inspect client alongside your existing XState dependency:

Terminal window
pnpm add -D @statelyai/inspect

Create an inspector and hand its inspect observer to definePlayer:

import { createBrowserInspector } from "@statelyai/inspect";
import { definePlayer } from "@xmachines/play-xstate";
import { appMachine } from "./machine.js";
const { inspect } = createBrowserInspector();
const createPlayer = definePlayer({
machine: appMachine,
options: { inspect },
});
const actor = createPlayer();
actor.start();

createBrowserInspector() opens Stately’s hosted inspector in a new tab and streams the actor’s events to it. From there the machine draws itself, every transition animates, and the context is readable at each step.

That is the whole integration. PlayerOptions.inspect is forwarded verbatim to XState’s createActor, so anything XState accepts there is accepted here — a function, or an observer object with a next method:

// Function form — the common case
definePlayer({ machine, options: { inspect: (event) => console.log(event.type) } });
// Observer form — also forwarded as-is
definePlayer({ machine, options: { inspect: { next: (event) => sink.write(event) } } });

Where the observer attaches

Two properties of the attachment point matter in practice.

It is configured on the factory, not on the instance. definePlayer({ machine, options }) returns a factory; options.inspect belongs to that configuration, and every actor the factory creates reports to the same observer. The factory’s per-call options bag carries snapshot for restoring persisted state and nothing else — there is no per-instance inspect override. When one factory produces several live actors (multi-user scenarios, SSR, tests), demultiplex the stream by root instead:

const createPlayer = definePlayer({ machine, options: { inspect } });
const alice = createPlayer({ userId: "alice" });
const bob = createPlayer({ userId: "bob" });
// Events from alice's tree, children included
const forAlice = (event: InspectionEvent) => event.rootId === alice.sessionId;

It is the only route that sees construction. Attaching after the fact with actor.system.inspect(fn) works and needs no factory changes, but it only receives events from the moment it subscribes — the @xstate.actor registration event has already fired by then, and an inspector that never receives it has no machine to draw. Use options.inspect when you want the full history; use actor.system.inspect(fn) when you only care about what happens next.


Reading the events

Because a PlayerActor is the XState actor rather than a wrapper around a hidden one, the events name it directly:

  • event.actorRef === actor for the player’s own events — recognise a player by identity, no bookkeeping required.
  • event.rootId === actor.sessionId for the whole tree, including invoked and spawned children, whose actorRef is the child rather than the player.

The event types are XState’s: @xstate.actor when an actor registers, @xstate.event when an event is sent, @xstate.snapshot after a transition, @xstate.action for executed actions, and @xstate.microstep for intermediate steps.

The construction caveat

@xstate.actor fires from inside the actor’s constructor. The actorRef it carries is a real PlayerActor, but a mid-construction one: state, currentRoute, currentView and initialRoute do not exist yet, and reading them there throws — XState itself refuses to read a snapshot in that window.

const inspect = (event: InspectionEvent) => {
if (event.type === "@xstate.actor") {
// ❌ throws — the signals are not assigned yet
console.log(event.actorRef.currentRoute.get());
}
if (event.type === "@xstate.snapshot") {
// ✅ construction has returned; signals are live
console.log(event.actorRef.getSnapshot().value);
}
};

Capture the reference during construction if you need it, and read the signals from a later event or from outside the observer entirely.


Turning the inspector on later

An inspector that is only created when the user asks for it — a dev-tools toggle, a keyboard shortcut, a debug panel button — cannot be passed to definePlayer, which ran at module scope long before the click. Pass a forwarding function instead, so the attachment point is fixed at factory time while the destination stays swappable:

let current: ((event: InspectionEvent) => void) | undefined;
const createPlayer = definePlayer({
machine: appMachine,
options: { inspect: (event) => current?.(event) },
});
// Later, from a click handler
export function enableInspector() {
const { inspect } = createBrowserInspector();
current = inspect;
}

This attaches at creation time (so nothing is missed structurally) while forwarding to nothing until the toggle flips.

There is a catch worth knowing before you ship it: events that arrive while current is undefined are dropped, so an inspector enabled after startup opens on a machine with no registration event and no history — it has nothing to draw until the next transition. If late opening should show the machine as it stands, buffer the events from creation and replay the buffer when the inspector connects. The demo controller described below does exactly that, and is worth reading as a worked example.


Inspecting without a browser

createBrowserInspector is one transport, not the only one. @statelyai/inspect also exports createWebSocketInspector, which pairs with createInspectorServer from @statelyai/inspect/server to stream the same events over a socket — the route to an actor with no browser around it at all, such as one running in a Node process or on the server half of an SSR render:

import { createWebSocketInspector } from "@statelyai/inspect";
const { inspect } = createWebSocketInspector({ url: "ws://localhost:8080" });
const createPlayer = definePlayer({ machine: appMachine, options: { inspect } });

Both transports consume the identical options.inspect observer, so switching between them touches only the line that creates the inspector. The same is true of an observer you write yourself: a console.log, a test spy asserting a transition sequence, or a writer that appends events to a log are all valid inspect values, and none of them require the inspect client at all.


Trying it in the demos

Every demo in this repository — all five renderer demos and all eight router demos — wires the inspector the same way, so any of them can be used to see the flow end to end:

Terminal window
pnpm --filter @xmachines/play-react-demo run dev

Click Show Inspector in the debug panel at the bottom of the page. The inspector opens in its own window with the machine already drawn and the session’s history replayed, and it keeps updating as you log in, navigate, and log out.

The demos share one controller, createDemoInspector() from @xmachines/play-actor-shared, which each demo passes straight through to definePlayer:

const inspector = createDemoInspector();
const actor = definePlayer({
machine: authMachine,
options: { inspect: inspector.inspect },
})();
actor.start();
// The debug panel's button
<button onClick={() => inspector.show()}>Show Inspector</button>;

The controller answers the late-opening problem from the previous section: it buffers serialized events from actor creation, pins the current root’s registration so it can never roll out of the bounded buffer, and flushes the buffer when the inspector window completes its handshake. That is a demo trade-off rather than a general recommendation — every transition pays serialization whether or not anyone ever opens the inspector — but it is what makes the button work at any point in a session. The shared package’s own README, at packages/play-actor/examples/shared/README.md in this repository, documents the design in full.


Production considerations

Cost. Inspection serializes events. A console.log observer is cheap; a browser inspector posting every transition, and any buffering scheme layered on top, is not. Gate the inspector behind a development-only branch, and prefer letting the bundler drop it entirely:

const options = import.meta.env.DEV ? { inspect: createBrowserInspector().inspect } : {};
const createPlayer = definePlayer({ machine: appMachine, options });

Data exposure. Inspection events carry the machine’s definition and its context — which in a real application may include user identifiers, tokens, or form input. The browser transport posts that data to the inspector’s origin over postMessage, and the WebSocket transport sends it to whatever server is listening. Treat an inspector connection as a data egress path: keep it out of production builds, and when inspecting a shared environment, know where the events are going.