Using XState machines
When an actor's lifecycle has real states and guarded transitions, back it with an XState machine. The machine constrains which transitions are legal; your onTransition handlers decide what happens when one fires.
1. Define the machine
import { createMachine } from 'xstate';
export const compareMachine = createMachine({
id: 'compare',
initial: 'comparing',
states: {
comparing: { on: { SELECT_FORK: 'comparing', ACCEPT_FORK: 'resolved', SEND_TO_REVIEWER: 'inReview' } },
inReview: { on: { APPROVE: 'resolved' } },
resolved: { type: 'final' },
},
});2. Attach it and handle transitions
import { defineBehavior } from '@actor-web/runtime';
const compare = defineBehavior<CompareEvent>()
.withMachine(compareMachine)
.onTransition({
ACCEPT_FORK: ({ actor }) => ({ reply: actor.getSnapshot().value }),
APPROVE: ({ actor }) => ({ reply: actor.getSnapshot().value }),
})
.build();The runtime checks the machine before running a handler: a message that isn't a legal transition from the current state is rejected, and the handler never runs.
Guards and rejection
Put guards in the machine. If SELECT_FORK isn't allowed in inReview, sending it there is a no-op rejection — you don't need defensive if checks in handlers.
When to prefer withFSM
If you only need transition constraints (no hierarchy, no XState actions), the lightweight withFSM constraint map avoids the XState dependency while keeping the same onTransition ergonomics.
Test it
Use @actor-web/testing to assert a machine has no unreachable states and to report transition coverage.