Skip to content

Latest commit

 

History

History
375 lines (286 loc) · 15.9 KB

File metadata and controls

375 lines (286 loc) · 15.9 KB

effect-machine Skill

Quick reference for AI agents working with effect-machine.

What It Is

Type-safe state machines for Effect. Schema-first API.

Core Pattern

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);

Key Methods

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.

State.with()

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 });

Effect Services

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.

Input, Output, and Composition

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.flatMap or Effect.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.

Running Actors

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))));

ActorRef API

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)

ask / reply

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.

Timeout & Postpone

// 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]);

ProcessEventResult

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 }>;
}

System Observation

// 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);

Testing

// 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");

Effect Atom

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.

Critical Gotchas

  1. Empty structs are values: State.Idle not State.Idle()
  2. yield after send: yield* Effect.yieldNow to process events
  3. simulate skips spawn: Use real actors for spawn effect tests
  4. Same-state skips lifecycle: Use .reenter() to force
  5. Never throw in Effect.gen: Use yield* Effect.fail()
  6. .onAny() is fallback: Specific .on() always takes priority
  7. Services at allocation time: provide Effect services when Machine.spawn allocates the actor
  8. call vs send: send = fire-and-forget, call = request-reply, ask = typed reply
  9. Non-Effect code: Use actor.client. React and Solid should use Actor Atoms.
  10. ActorStoppedError: Pending call/ask Deferreds settled on stop

Cluster / Entity Machines

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

Files

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

Lazy actors owned by a parent state

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.