Quick reference for AI agents working with effect-machine.
Type-safe state machines for Effect. Schema-first API.
import { Machine, State, Event } from "effect-machine";
// 1. Define schemas
const MyState = State({
Idle: {},
Loading: { url: Schema.String },
Done: { data: Schema.Unknown },
});
const MyEvent = Event({
Start: { url: Schema.String },
Complete: { data: Schema.Unknown },
});
// 2. Build machine
const machine = Machine.make({
state: MyState,
event: MyEvent,
initial: MyState.Idle,
})
.on(MyState.Idle, MyEvent.Start, ({ event }) => MyState.Loading({ url: event.url }))
.on(MyState.Loading, MyEvent.Complete, ({ event }) => MyState.Done({ data: event.data }))
.final(MyState.Done);| Method | Purpose |
|---|---|
.on(state, event, handler) |
Add transition |
.when(state, event, predicate, h) |
Add conditional transition |
.on([stateA, stateB], event, h) |
Multi-state transition |
.onAny(event, handler) |
Wildcard (any state, specific .on wins) |
.reenter(state, event, handler) |
Force lifecycle on same-state |
.reenterWhen(state, event, p, h) |
Conditional forced lifecycle |
.immediate(state, handler) |
Eventless transition until stable |
.immediateWhen(state, p, handler) |
Conditional eventless transition |
.spawn(state, handler) |
State-scoped effect (auto-cancelled) |
.timeout(state, { duration, event }) |
State timeout (gen_statem) |
.postpone(state, event/events) |
Postpone event in state (gen_statem) |
.background(handler) |
Machine-lifetime effect |
.final(state) |
Mark final state |
Use self.client in a host callback that must send or observe state synchronously. Use
self.state and self.latestTransition in Effect workflows. A background resource receives the
actor generation Scope. The runtime closes that Scope when the actor generation stops.
Construct state from existing source:
// Per-variant: preserve fields, override specific ones
State.Active.with(state, { count: state.count + 1 });
// Cross-state: picks only target fields
State.Shipped.with(processingState, { trackingId: "TRACK-123" });
// Empty variant
State.Idle.with(anyState); // → { _tag: "Idle" }
// Union-level: dispatches to correct variant based on _tag
// Preserves specific variant subtype — no switch needed
const updated = MyState.with(state, { queue: newQueue });class Api extends Context.Service<Api, { readonly fetch: (url: string) => Effect.Effect<Data> }>()(
"app/Api",
) {}
const machine = Machine.make({ state, event, initial }).task(
State.Loading,
({ state }) => Effect.flatMap(Api, (api) => api.fetch(state.url)),
{ onSuccess: (data) => Event.Loaded({ data }) },
);
const actor = yield * Machine.spawn(machine).pipe(Effect.provideService(Api, { fetch: Http.get }));
yield * actor.start;Task, spawn, background, transition handlers, and .when() predicates can require Effect services.
Guards are named and ordered. The first passing candidate wins. An unguarded candidate is a fallback.
machine.when(
State.Idle,
Event.Go,
({ state }) => state.allowed,
({ state }) =>
Effect.gen(function* () {
const audit = yield* Audit;
yield* audit.record(state);
return State.Ready;
}),
);Transition Effects finish before state subscribers run. Their error channel must be never. Convert expected failures to states or events. .immediate() edges also settle before state subscribers run.
Machine.spawn captures the current Effect context. A later actor.start keeps those services.
All handler and predicate requirements flow into the machine type. Machine.spawn(machine) and system.spawn(id, machine) keep those requirements until the caller provides them.
Use the Effect context as the only runtime requirement channel. Use .task() for work that sends a completion event. Use .spawn() for state-owned work. Use .background() for actor-owned work. Use an Effectful transition only when its result must select the next state before the mailbox can continue.
const machine = Machine.make({
state,
event,
initial: (input: { readonly id: string }) => State.Loading({ id: input.id }),
}).final(State.Done, ({ state }) => state.value);
const actor = yield * Machine.spawn(machine, { input: { id: "item-1" } });
yield * actor.start;
const output = yield * actor.awaitOutput;- Input creates the initial state. It does not replace Effect context.
- Output is separate from the retained final state.
- Compose autonomous runs with
Effect.flatMaporEffect.gen. - Use
Machine.run(machine, options)to start, await output, and always stop one autonomous actor. - Use a parent machine when interactive phases must remain visible together.
- Do not add an action queue. Model external work with Effect handlers.
Simple (no registry):
const program = Effect.gen(function* () {
const actor = yield* Machine.spawn(machine);
yield* actor.start;
yield* actor.send(Event.Start({ url: "/api" }));
const state = yield* actor.waitFor(MyState.Done);
});
Effect.runPromise(Effect.scoped(program));With registry/persistence:
const program = Effect.gen(function* () {
const system = yield* ActorSystemService;
const actor = yield* system.spawn("id", machine);
// ...
});
Effect.runPromise(Effect.scoped(program.pipe(Effect.provide(ActorSystemDefault))));| Method | Description |
|---|---|
actor.send(event) |
Fire-and-forget (queue event) |
actor.call(event) |
Request-reply, returns ProcessEventResult |
actor.ask(event) |
Typed reply (event must have Event.reply) |
actor.waitFor(State.X) |
Wait for state (constructor or fn) |
actor.sendAndWait(ev, State.X) |
Send + wait for state |
actor.awaitFinal |
Wait for final state |
actor.awaitOutput |
Wait for typed final output |
actor.awaitExit |
Completes when this actor stops |
actor.drain |
Process remaining queue, then stop |
actor.snapshot |
Get current state |
actor.client.send(event) |
Fire-and-forget outside Effect |
actor.client.stop() |
Start a stop outside Effect |
actor.client.getSnapshot() |
Get state outside Effect |
actor.client.matches(tag) |
Check a state tag outside Effect |
actor.client.canSync(event) |
Check Boolean predicates outside Effect |
actor.client.can(event) |
Promise check for all predicates |
actor.lifecycle |
Observable actor lifecycle |
actor.latestTransition |
Retained latest accepted edge |
actor.subscribe(fn) |
Sync callback, returns unsubscribe |
actor.system |
Access the actor's ActorSystem |
actor.children |
Child actors (ReadonlyMap) |
Events declare reply schemas via Event.reply(). Handlers use Machine.reply():
const MyEvent = Event({
GetCount: Event.reply({}, Schema.Number), // askable
Reset: {}, // not askable
});
.on(State.Active, Event.GetCount, ({ state }) =>
Machine.reply(state, state.count),
)
const count = yield* actor.ask(Event.GetCount); // number — type inferred from schema
// actor.ask(Event.Reset) — compile error (no reply schema)Deferred replies via Machine.deferReply() — spawn handler settles later via self.reply(value).
Fails with NoReplyError if handler doesn't reply, ActorStoppedError on stop.
// State timeout — timer auto-cancelled on state exit
machine.timeout(State.Loading, {
duration: Duration.seconds(30),
event: Event.Timeout,
});
// Event postpone — buffered, drained on next state change
machine.postpone(State.Connecting, [Event.Data, Event.Cmd]);Returned by actor.call(event):
interface ProcessEventResult<S> {
newState: S;
previousState: S;
transitioned: boolean;
lifecycleRan: boolean;
isFinal: boolean;
hasReply: boolean;
reply?: unknown;
postponed: boolean;
transitions: ReadonlyArray<{ previousState: S; newState: S; event: E }>;
}// Sync callback — ActorSpawned / ActorStopped events
const unsub = system.subscribe((event) => console.log(event._tag, event.id));
// Sync snapshot of all registered actors
const actors: ReadonlyMap<string, ActorRef> = system.actors;
// Async stream (late subscribers miss prior events)
system.events.pipe(Stream.take(10), Stream.runCollect);// Simulate (no spawn effects)
const result = yield * simulate(machine, [Event.Start, Event.Complete]);
expect(result.finalState._tag).toBe("Done");
// Assert path
yield * assertPath(machine, events, ["Idle", "Loading", "Done"]);
// Real actor — call-based testing
const actor = yield * Machine.spawn(machine);
const result = yield * actor.call(Event.Start);
expect(result.transitioned).toBe(true);
expect(result.newState._tag).toBe("Loading");Use effect-machine/atom with an Effect Atom framework binding.
import * as ActorAtom from "effect-machine/atom";
const actorAtom = ActorAtom.make(actor);
const countAtom = ActorAtom.select(actorAtom, (state) => state.count);
const lifecycleAtom = ActorAtom.lifecycle(actor);
const latestTransitionAtom = ActorAtom.latestTransition(actor);
const canStartAtom = ActorAtom.can(actor, Event.Start);The Atom value is actor state. Atom writes send actor events. A selected Atom stays writable.
For a Suspense-owned actor, create it through an effect-backed Atom:
const actorResource = Atom.make(Machine.scoped(spawnActor));Use useAtomSuspense in React. Use useAtomResource in Solid. The Atom scope stops the actor when the resource is released.
An exit animation can keep a screen mounted after a final transition. Keep required display fields in the final state. Do not retain the last selector value in component state or a ref.
- Empty structs are values:
State.IdlenotState.Idle() - yield after send:
yield* Effect.yieldNowto process events - simulate skips spawn: Use real actors for spawn effect tests
- Same-state skips lifecycle: Use
.reenter()to force - Never throw in Effect.gen: Use
yield* Effect.fail() .onAny()is fallback: Specific.on()always takes priority- Services at allocation time: provide Effect services when
Machine.spawnallocates the actor - call vs send:
send= fire-and-forget,call= request-reply,ask= typed reply - Non-Effect code: Use
actor.client. React and Solid should use Actor Atoms. - ActorStoppedError: Pending
call/askDeferreds settled on stop
Wire machines to @effect/cluster for distributed actors:
import { toEntity, EntityMachine, PersistenceAdapter } from "effect-machine/cluster";
const OrderEntity = toEntity(orderMachine, { type: "Order" });
const OrderEntityLayer = EntityMachine.layer(OrderEntity, orderMachine, {
initializeState: (entityId) => OrderState.Pending({ orderId: entityId }),
persistence: { strategy: "journal" }, // or "snapshot" (default)
});Input machines require input: (entityId) => Input. Use initializeState only to override the complete initial state.
| Export | Purpose |
|---|---|
toEntity(machine, { type }) |
Generate Entity definition with Send/Ask/GetState/WatchState RPCs |
EntityMachine.layer(entity, machine, opts?) |
Wire machine to cluster Entity layer |
makeEntityActorRef(client, id) |
Typed client wrapper (send/ask/snapshot/watch/waitFor) |
PersistenceAdapter |
Service tag for storage backend |
makeInMemoryPersistenceAdapter |
In-memory adapter for testing |
Persistence strategies:
- snapshot: background scheduler + deactivation finalizer. No journal.
- journal: inline event append on each RPC, replay on reactivation. Deactivation snapshot as fallback.
EntityMachineOptions: input, initializeState, maxIdleTime, mailboxCapacity, disableFatalDefects, defectRetryPolicy, persistence
| File | Purpose |
|---|---|
machine.ts |
Machine builder |
schema.ts |
State/Event schemas and copy helpers |
actor.ts |
ActorSystem, event loop |
testing.ts |
simulate, harness |
internal/runtime.ts |
Shared runtime kernel (entity-machine) |
internal/machine-initialization.ts |
Machine input resolution seam |
cluster/entity-machine.ts |
Entity-machine adapter + persistence |
cluster/persistence.ts |
Adapter interface, types, service tag |
cluster/adapters/in-memory.ts |
In-memory persistence adapter |
cluster/entity-actor-ref.ts |
Typed entity client wrapper |
cluster/to-entity.ts |
Entity definition generator |
examples/core |
Runnable Effect and actor patterns |
examples/react |
React Suspense and selector example |
examples/solid |
Solid Suspense and selector example |
docs |
User and migration guides |
Create ActorHost.make({ identity, spawn }) in a scoped service layer. Use host.host(input) in a state-scoped .spawn handler. Use host.acquire(input) from consumers. Identity uses Object.is. The first matching consumer supplies the spawn input. Concurrent consumers share startup. Consumer cancellation does not stop startup or the actor. The parent state scope owns the actor, and host service shutdown closes any active generation. Keep session validation in the application.
Parents can pass typed data with host.host(input, hostInput). Type the second spawn argument to receive it. This data belongs to the host generation; consumers still call acquire(input). Pass immutable values.
ActorHost starts direct Machine.spawn results too. A registered host wait is interrupted on generation close; consumers receive ActorHostClosedError before actor cleanup. A consumer attached to an old generation must acquire again after reentry. Handle expected factory errors in the parent's spawn handler. Use Effect.orDie only for invariant failures, because it defects the parent. Both host errors are exported from the package root.