Skip to content

machine

@statorjs/stator/machine is the engine itself — browser-safe (no server imports), running identically on the server and in a client island.

function defineMachine(config: DefineMachineConfig): MachineDef
// config fields
{
name: string
lifecycle: 'app' | 'session'
context: C // initial context
initial: S // initial state name
states: Record<S, { on?: OnMap; entry?: EntryEffect; after?: AfterEntry[] }>
on?: OnMap // machine-level transitions: apply in ANY state, consulted only
// when the current state doesn't declare the event (a state-
// scoped handler always wins, even if its guard drops)
events?: E // typed event surface — pass `{} as MyEvents`
selectors?: Record<string, (ctx: C, helpers?) => unknown> // helpers.reads for cross-machine views
reads?: MachineDef[] // machines this one reads (typed helpers.reads)
subscribes?: SubscribeEntry[] // cross-machine subscriptions
emits?: string[] | Record<string, { payload?: (ctx, ev) => object }>
persist?: boolean // app machines only: survive restarts (while the machine's
// code is unchanged) via the AppStore
}

Defines a machine: flat states, typed events, and inline transitions. events is a phantom carrier — the engine reads only its type, and each transition’s action/guard then sees the event narrowed to exactly its on key. A machine that declares reads gets a typed helpers.reads map (keyed by machine name, selectors preserved) in its actions, guards, and selectors — reads-aware selectors project cross-machine verdicts as display state, and bindings on the reading machine re-diff when a read machine changes. Declaring reads server-pins the machine, since cross-machine reads can’t resolve in the browser. persist: true on a session machine is an error; sessions always persist through the session Store.

Snapshots are bound to the machine’s code. Every persisted snapshot is stamped with a hash of the machine’s code (the file plus every module it reaches, tree-shaken), and a snapshot whose hash no longer matches the running machine is discarded at hydration — the machine starts fresh. Machine state is working state, not persistence; see What survives a deploy for the rule, what moves the hash, and the reload-on-entry idiom for durable facts.

Each entry in an on map is a transition (or an ordered array of guarded candidates — first passing when wins):

{
to?: S // target state; omit for a self-transition
when?: (ctx, ev, helpers) => boolean
do?: (ctx, ev, helpers) => void // mutates a draft; the engine owns clone + commit
emit?: string | string[] // declared emits fired after the action commits
effect?: (ctx, ev, meta) => Promise<Events | null>
}

A bare function is sugar for { do: fn }.

function createActor(def: MachineDef, opts?: CreateActorOptions): Actor
interface CreateActorOptions {
snapshot?: Snapshot // restore from persisted state or a client seed
resolveHelpers?: () => ActionHelpers // host-provided `reads` resolver
onEffect?: (invocation: EffectInvocation) => void // host effect scheduler
}
interface Actor<C, E> {
start(): Actor<C, E>
stop(): void
send(event: E): void
seed(partial: Partial<C>): void // merge into context before start(); no-op after
getSnapshot(): Snapshot<C>
getPersistedSnapshot(): Snapshot<C>
subscribe(listener: (snapshot: Snapshot<C>) => void): { unsubscribe(): void }
on(emitName: string, listener: (event) => void): () => void
}

Instantiates a running machine. The two injection points are what keep the engine isomorphic: the server wires resolveHelpers to the active dispatch context and onEffect to its post-commit queue; the client omits both, so effects run locally on a microtask and a reads dereference throws with a clear error. You call this directly in unit tests; the framework calls it everywhere else.

type Effect = (ctx: C, ev: E, meta: EffectMeta) => Promise<Events | null>
interface EffectMeta {
effectId: string
signal?: AbortSignal // aborts on state exit — entry effects only
}
interface EffectInvocation {
machineName: string
effectId: string
kind: 'entry' | 'transition'
stateKey?: string // owning state — set for entry effects
run: (signal?: AbortSignal) => Promise<EventObject | null>
}

An effect is async I/O declared on a transition and run by the host after the transition commits — the engine itself never performs I/O. It receives structuredClone snapshots of context and event taken at commit time (never live state), plus meta.effectId, unique per invocation — thread it to external calls as an idempotency key and use it for log correlation. Return the completion event to dispatch, or null for fire-and-forget.

Two rules to know:

  • Annotate the return type: effect: async (ctx, ev, meta): Promise<Events | null> => …. TypeScript defers context-sensitive arrows during defineMachine’s inference, so an unannotated effect fails to typecheck; the annotation restores full checking of the completion event against your event union.
  • Effects are infallible by construction: catch inside and return your declared failure event. A throw is the runtime backstop — logged and dropped, never a crash.
interface EffectMeta {
effectId: string
signal?: AbortSignal
session?: { id: string; claims<T = unknown>(): T | undefined }
}

For session machines the server host sets meta.session on every effect — the session id and its app-defined claims as of the moment the effect started (the same claims middleware reads with stator(c).claims()). It is what lets an entry effect reload a durable fact by identity on a fresh start or after a snapshot reset, with no client round trip. App machines have no session and client islands run no host, so it is undefined there.

type EntryEffect = (ctx: C, meta: EffectMeta) => Promise<Events | null>
states: {
loading: {
entry: async (ctx, meta): Promise<Events | null> => {
const data = await fetchForecast(ctx.location, { signal: meta.signal })
return { type: 'LOADED', data }
},
on: { LOADED: { to: 'ready', do: (ctx, ev) => { ctx.data = ev.data } } },
},
}

A state’s entry is async I/O the host schedules when the state is entered — a fresh start at the initial state, or a value-changing transition (never on a bare snapshot restore). Same host-scheduled, off-lock pipeline as a transition effect, minus the event argument (a state entry has no triggering event).

The role split matters: entry effects are the load role and transition effects are the command role. The host may re-invoke an entry effect whose completion never settled (a process died mid-flight) and aborts meta.signal when the state is exited — so write entry effects as re-runnable reads, and keep non-idempotent external writes (charges, sends) in transition effects, which are at-most-once and never aborted. See the effects guide.

interface AfterEntry {
delay: number | ((ctx: C) => number)
send: Events
}
states: {
shown: { after: [{ delay: 5_000, send: { type: 'DISMISS' } }] },
}

Each after entry dispatches send after delay ms in the state — armed on entry, cancelled on exit. Host-scheduled and in-memory: a restart drops armed timers (a host restoring a snapshot re-arms with elapsed credit via enteredAt). Durable schedules are deferred work.

function describeMachine(def: AnyMachineDef): MachineDescription

Serializes a machine def to plain JSON-able data — the def is the statechart, and this walks it: states, per-event transition candidates (to, guard/action/effect presence, emit names), entry effects and after timers (delay reported as its number, or 'dynamic' for a context-dependent one), machine-level fallback handlers, the handled-event list, serverOnly, emit and selector names, reads/subscribes (as machine names), and the initial context. Closures stay opaque — a guard or effect reports presence, never its body. Pure and side-effect free; it never invokes app code.

This is the introspection substrate: the dev inspector’s Machines tab is built on it, and it’s the walk to reach for when building your own tooling over a machine’s shape. MachineDescription, StateDescription, and TransitionDescription are exported alongside it.

Type-level plumbing, exported for tooling and advanced typing:

  • EventObject — the base event shape ({ type: string }).
  • EventOf<Def> — a machine’s event union; what dispatch(Machine, event) checks against.
  • InstanceOf<Def> — a machine’s instance shape (each selector as a typed property).
  • ReadsMap<Reads> — the typed helpers.reads map built from a reads tuple.
  • Snapshot<C>{ value: string[]; context: C; enteredAt?: number; pendingEntry?: { effectId: string } }; serializes to the Store and seeds a client island on upgrade. enteredAt lets a restoring host re-arm after timers with elapsed credit; pendingEntry marks an entry effect whose completion never settled, so the host re-invokes it.
  • Action, Guard, ActionHelpers — the function shapes transitions are built from.
  • EntryEffect, AfterEntry — the state-entry effect and timeout-entry shapes.
  • Transition, TransitionConfig, StateNode — the transition-graph node types.
  • EmitDeclaration, EmitsConfig — normalized emit declarations.
  • SubscribeEntry — a cross-machine subscription entry.
  • Capabilities{ serverPinned, reasons }; why a machine can’t run client-side.
  • Lifecycle'app' | 'session'.
  • MachineDef, AnyMachineDef, AnyActor — the def/actor types and their heterogeneous-collection “top types”.
  • isStatorMachine — brand guard for a machine def.