| name | effect-machine |
| description | Type-safe state machines for Effect. Use when building state machines with effect-machine — defining states/events, transition handlers, spawn effects, timeouts, postpone, actors, typed ask/reply, testing, recovery/durability lifecycle. Triggers on effect-machine imports, Machine.make, Machine.spawn, actor.start, Machine.replay, Machine.reply, Event.reply, State/Event definitions, ActorRef usage, Recovery, Durability, Lifecycle. |
Navigation
What are you building?
├─ Defining states/events → §Schema-First
├─ Writing transition handlers → §Transitions
├─ Adding side effects → §Effects
├─ Testing machines → §Testing
├─ Running actors → §Actors
├─ Typed ask/reply → §Ask / Reply
├─ Recovery/durability → §Lifecycle
├─ Timeouts / postpone → §Timeouts, §Postpone
└─ Slots (guards/effects) → §Slots
Schema-First
States and events ARE schemas. State({}) and Event({}) produce tagged unions with constructors.
import { Schema } from "effect";
import { State, Event } from "effect-machine";
const S = State({
Idle: {},
Loading: { url: Schema.String },
});
const E = Event({
Start: { url: Schema.String },
Done: { data: Schema.Unknown },
GetCount: Event.reply({}, Schema.Number),
GetInfo: Event.reply({ id: Schema.String }, Schema.String),
});
derive — construct from existing state, picks overlapping fields:
S.Active.derive(state);
S.Active.derive(state, { count: n + 1 });
Type guards / matching:
S.$is("Loading")(value)
S.$match(value, { Loading: (s) => ..., _: () => ... })
Transitions
const machine = Machine.make({ state: S, event: E, initial: S.Idle })
.on(S.Idle, E.Start, ({ event }) => S.Loading({ url: event.url }))
.on([S.Loading, S.Retrying], E.Done, ({ event }) => S.Active({ data: event.data }))
.onAny(E.Cancel, () => S.Cancelled)
.reenter(S.Active, E.Refresh, ({ state }) => S.Active.derive(state))
.final(S.Done)
.final(S.Cancelled);
Handler return types:
({ state, event }) => S.Next({ ... })
({ state }) => Effect.gen(function* () { ... return S.Next({ ... }) })
({ state }) => Machine.reply(S.Same.derive(state), state.count)
Effects
spawn — state-scoped, auto-cancelled on exit
machine.spawn(S.Loading, ({ state, self }) =>
Effect.gen(function* () {
const data = yield* fetchData(state.url);
yield* self.send(E.Done({ data }));
}),
);
task — spawn + auto-route success/failure
machine.task(S.Loading, ({ state }) => fetchData(state.url), {
onSuccess: (data) => E.Done({ data }),
onFailure: () => E.Error,
});
background — machine-lifetime (not state-scoped)
machine.background(({ self }) =>
Stream.fromSchedule(Schedule.spaced("10 seconds")).pipe(
Stream.runForEach(() => self.send(E.Heartbeat)),
),
);
Slots
Unified parameterized slots via Slot.define + Slot.fn. Handlers take only params:
import { Slot } from "effect-machine";
const MySlots = Slot.define({
canRetry: Slot.fn({ max: Schema.Number }, Schema.Boolean),
notify: Slot.fn({ msg: Schema.String }),
});
const machine = Machine.make({
state: S,
event: E,
slots: MySlots,
initial: S.Idle,
})
.on(S.Error, E.Retry, ({ slots, state }) =>
Effect.gen(function* () {
if (yield* slots.canRetry({ max: 3 })) return S.Loading.derive(state);
return S.Failed;
}),
)
.spawn(S.Done, ({ slots, state }) => slots.notify({ msg: `Done: ${state.id}` }));
const actor =
yield *
Machine.spawn(machine, {
slots: {
canRetry: ({ max }) => attempts < max,
notify: ({ msg }) => Effect.log(msg),
},
});
yield * actor.start;
Slots are accepted everywhere: Machine.spawn, Machine.replay, simulate, createTestHarness.
Ask / Reply
Events declare reply schemas via Event.reply(fields, schema). Handlers must use Machine.reply():
const E = Event({
GetCount: Event.reply({}, Schema.Number),
Reset: {},
});
machine.on(S.Active, E.GetCount, ({ state }) => Machine.reply(S.Active.derive(state), state.count));
const count = yield * actor.ask(E.GetCount);
Rules:
Event.reply({}, schema) — empty payload + reply; Event.reply({ id: Schema.String }, schema) — payload + reply
- Handler for reply-bearing event MUST return
Machine.reply(state, value) — plain state return is a type error
- Handler for non-reply event CANNOT return
Machine.reply() — type error
.onAny() handlers cannot provide replies — use specific .on() for reply events
- Reply decode mismatch (handler returns wrong type) = defect at runtime
- Cluster:
Ask RPC propagates replies through entity boundary
Timeouts
gen_statem-style. Timer starts on state entry, cancels on exit:
machine.timeout(S.Loading, {
duration: Duration.seconds(30),
event: E.Timeout,
});
machine.timeout(S.Retrying, {
duration: (state) => Duration.seconds(state.backoff),
event: E.GiveUp,
});
.reenter() restarts the timer with fresh state values.
Postpone
gen_statem-style. Buffered events drain FIFO on state change, looping until stable:
machine.postpone(S.Connecting, E.Data).postpone(S.Connecting, [E.Data, E.Command]);
Multi-stage: if a drained event causes another state change, postponed events re-drain.
Actors
Machine.spawn — standalone, unstarted actor
Machine.spawn returns a cold actor. Call actor.start to fork the event loop. Events sent before start() are queued.
const actor = yield * Machine.spawn(machine);
yield * actor.start;
const actor = yield * Machine.spawn(machine, { id: "my-id", hydrate: savedState });
yield * actor.start;
Auto-cleans up if Scope is present. Otherwise call actor.stop manually.
ActorRef API
| Method | Description |
|---|
start | Fork event loop + effects (required after Machine.spawn) |
send(event) | Fire-and-forget |
cast(event) | Alias for send |
call(event) | Request-reply → ProcessEventResult |
ask(event) | Typed reply (event must have Event.reply() schema) |
snapshot | Current state |
changes | Stream<State> (SubscriptionRef-backed) |
transitions | Stream<{ fromState, toState, event }> (PubSub-backed edge stream) |
waitFor(S.X) | Wait for state |
sendAndWait(ev, S.X) | Send + wait |
awaitFinal | Wait for final state |
sync.* | Sync variants for non-Effect boundaries |
ActorSystem — registry + lifecycle (auto-starts)
system.spawn auto-starts — no actor.start needed.
const system = yield * ActorSystemService;
const actor = yield * system.spawn("id", machine);
const maybe = yield * system.get("id");
yield * system.stop("id");
system.actors;
system.events;
Child actors
machine.spawn(S.Active, ({ self }) =>
Effect.gen(function* () {
const child = yield* self.spawn("worker", workerMachine).pipe(Effect.orDie);
yield* child.send(WorkerEvent.Start);
}),
);
Lifecycle
Recovery + Durability hooks for persistence. Passed via lifecycle option on Machine.spawn / system.spawn.
const actor =
yield *
Machine.spawn(machine, {
lifecycle: {
recovery: {
resolve: ({ actorId, generation, machineInitial }) =>
storage.get(actorId).pipe(Effect.map(Option.fromNullable)),
},
durability: {
save: ({ actorId, generation, previousState, nextState, event }) =>
storage.set(actorId, nextState),
shouldSave: (state, prev) => state._tag !== prev._tag,
},
},
});
yield * actor.start;
| Interface | When it runs | Receives |
|---|
Recovery<S> | During actor.start (and supervision restart) | { actorId, generation, machineInitial } |
Durability<S, E> | After each state commit, before reply settlement | { actorId, generation, previousState, nextState, event } |
generation — 0 = cold start, 1+ = supervision restart
hydrate overrides recovery — Machine.spawn(machine, { hydrate: state }) skips resolve entirely
Lifecycle<S, E> = { recovery?, durability? } — both optional
Replay + Hydrate
const actor = yield * Machine.spawn(machine, { hydrate: loadedState });
yield * actor.start;
const state = yield * Machine.replay(machine, events);
const actor = yield * Machine.spawn(machine, { hydrate: state });
yield * actor.start;
const state = yield * Machine.replay(machine, tailEvents, { from: snapshot });
const actor = yield * Machine.spawn(machine, { hydrate: state });
yield * actor.start;
Machine.replay semantics:
- Folds events through transition handlers (pure or effectful)
self.send/self.spawn are no-ops (stubbed)
- Spawn effects, background effects, timeouts do NOT run
- Postpone rules respected (loop until stable)
- Final state stops replay
- Unhandled events silently skipped
Testing
import { simulate, assertPath, assertReaches, createTestHarness } from "effect-machine";
const { states, finalState } = yield * simulate(machine, [E.Start, E.Done]);
yield * assertPath(machine, events, ["Idle", "Loading", "Done"]);
yield * assertReaches(machine, events, "Done");
yield * assertNeverReaches(machine, events, "Error");
const harness = yield * createTestHarness(machine);
yield * harness.send(E.Start);
expect(harness.state._tag).toBe("Loading");
Both simulate and createTestHarness accept Machine directly.
Gotchas
Machine.spawn returns unstarted actor — must call yield* actor.start. system.spawn auto-starts.
- Slots are provided at spawn time —
Machine.spawn(machine, { slots: { ... } })
- Empty state = value, non-empty = constructor —
S.Idle vs S.Loading({ url })
- Spawn effects re-run on hydrate —
Machine.spawn({ hydrate }) re-runs spawn effects for the hydrated state (timers, scoped resources)
hydrate overrides recovery — resolve() is never called when hydrate is set
transitions is observational — PubSub-backed, late subscribers miss edges. Not a durability guarantee.
- Effectful handlers in replay — replay runs handlers but stubs
self/system. Side effects through self.send are no-ops.
ask() requires reply schema — only events with Event.reply() accepted; non-reply events are type errors
- Reply decode failure = defect — if handler returns wrong type, actor dies (broken handler, not business logic)
- v3 compat — import from
"effect-machine/v3" for Effect v3 projects