Skills

Install

$ npx ai-agents-skills add --skill state-machines-pattern
Domain v1.0

State Machine Pattern

Models application logic as a set of explicit states with deterministic transitions between them. At any moment, the system is in exactly one state. Transitions happen in response to events and may be guarded by conditions.

State machines eliminate impossible states — the root cause of most state management bugs — by making invalid combinations structurally impossible rather than handled by if/else chains.

When to Use

  • Async operations with loading/success/error states
  • Multi-step user flows (onboarding, checkout, form wizard)
  • Business workflows with defined rules (order lifecycle, document approval)
  • Any logic where boolean flag combinations produce impossible states

Don’t use for:

  • Simple toggle state (open/closed, visible/hidden) — useState is sufficient
  • Pure data transformations with no state transitions
  • Server-side CRUD with no meaningful workflow logic

Critical Patterns

✅ REQUIRED: Explicit State Union

Replace boolean flag combinations with an exhaustive union type. Every possible state is named and intentional.

// ❌ WRONG: boolean flags — 2³ = 8 combinations, most impossible
interface FetchState {
  isIdle: boolean;
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;  // can isLoading and isError both be true? Unclear.
}

// ✅ CORRECT: exactly one state at a time, all valid states named
type FetchState = 'idle' | 'loading' | 'success' | 'error';

With context (extended state):

type OrderState =
  | { status: 'pending' }
  | { status: 'confirmed'; confirmedAt: Date }
  | { status: 'shipped';   trackingNumber: string }
  | { status: 'delivered'; deliveredAt: Date }
  | { status: 'cancelled'; reason: string };

// TypeScript narrows the context based on state — impossible states don't type-check
function getTracking(order: OrderState): string {
  if (order.status === 'shipped') return order.trackingNumber; // ✅ safe
  // order.trackingNumber doesn't exist on other states — compile error if misused
}

✅ REQUIRED: Transition Table

Define which transitions are valid. Anything not in the table is rejected — no silent no-ops or invalid state corruption.

type OrderEvent = 'CONFIRM' | 'SHIP' | 'DELIVER' | 'CANCEL';

const transitions: Record<string, Partial<Record<OrderEvent, string>>> = {
  pending:   { CONFIRM: 'confirmed', CANCEL: 'cancelled' },
  confirmed: { SHIP: 'shipped',      CANCEL: 'cancelled' },
  shipped:   { DELIVER: 'delivered' },
  delivered: {},           // terminal — no transitions out
  cancelled: {},           // terminal — no transitions out
};

function transition(current: string, event: OrderEvent): string {
  const next = transitions[current]?.[event];
  if (!next) throw new Error(`Invalid transition: ${current} + ${event}`);
  return next;
}

// transition('pending', 'DELIVER')  → throws — invalid
// transition('pending', 'CONFIRM')  → 'confirmed'

✅ REQUIRED: Guards

Predicates that allow or prevent a transition based on context. Guards keep conditional logic explicit and testable.

interface OrderContext {
  items: OrderItem[];
  paymentConfirmed: boolean;
}

const guards = {
  canConfirm: (ctx: OrderContext) => ctx.items.length > 0 && ctx.paymentConfirmed,
  canCancel:  (ctx: OrderContext) => true,  // always allowed from valid states
};

function confirm(state: OrderState, ctx: OrderContext): OrderState {
  if (state.status !== 'pending') throw new Error('Only pending orders can be confirmed');
  if (!guards.canConfirm(ctx))    throw new Error('Cannot confirm: payment required');
  return { status: 'confirmed', confirmedAt: new Date() };
}

✅ REQUIRED: Actions (Side Effects on Transition)

Actions are side effects executed when entering a state, exiting a state, or during a transition. They are separate from the state definition — the machine defines what happens, actions define how.

type Action = 'sendConfirmationEmail' | 'reserveInventory' | 'notifyShipping';

const transitionActions: Record<string, Partial<Record<OrderEvent, Action[]>>> = {
  pending: {
    CONFIRM: ['sendConfirmationEmail', 'reserveInventory'],
  },
  confirmed: {
    SHIP: ['notifyShipping'],
  },
};

async function send(event: OrderEvent, state: OrderState, ctx: OrderContext): Promise<OrderState> {
  const actions = transitionActions[state.status]?.[event] ?? [];
  const nextState = transition(state.status, event);

  // Execute actions after state transition
  for (const action of actions) {
    await executeAction(action, ctx);
  }

  return { status: nextState } as OrderState;
}

✅ REQUIRED: Context (Extended State)

Data that travels alongside the machine’s state. Context changes are triggered by transitions, not direct mutation.

interface FetchMachine {
  state: 'idle' | 'loading' | 'success' | 'error';
  context: {
    data: unknown | null;
    error: string | null;
    retries: number;
  };
}

// ✅ Context changes only through transitions
function fetchReducer(machine: FetchMachine, event: FetchEvent): FetchMachine {
  switch (machine.state) {
    case 'idle':
      if (event.type === 'FETCH') return { state: 'loading', context: { ...machine.context, data: null, error: null } };
      break;
    case 'loading':
      if (event.type === 'SUCCESS') return { state: 'success', context: { ...machine.context, data: event.data } };
      if (event.type === 'FAILURE') return { state: 'error',   context: { ...machine.context, error: event.error } };
      break;
  }
  return machine; // unknown event — no-op
}

❌ NEVER: Boolean Flag Accumulation

Every boolean flag added to manage state is a symptom of missing state modeling.

// ❌ WRONG: 4 booleans = 16 combinations, 12 impossible
const [isIdle, setIsIdle]       = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError]     = useState(false);

// What does isLoading=true AND isError=true mean? Nothing valid.

// ✅ CORRECT: one state variable, all combinations explicit
const [state, setState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');

❌ NEVER: Direct State Mutation

Always send events through the machine. Direct mutation bypasses guards, actions, and the transition table.

// ❌ WRONG: bypasses all machine logic
order.status = 'shipped'; // no guards, no actions, no validation

// ✅ CORRECT: machine enforces all rules
order = transition(order, 'SHIP', context); // runs guard + transition + actions

Decision Tree

Multi-step workflow with clear rules?       → State machine pattern
Async operation (loading/success/error)?    → State machine (3-state minimum)
Boolean flags causing impossible states?    → Refactor to state union type

How complex is it?
  → 2-4 states, no actions     → useState + union type (no library needed)
  → 5+ states or actions       → Consider XState or a reducer pattern
  → Parallel regions            → XState (parallel states)
  → Persisted / rehydrated      → XState (serializable state)

State machine vs Redux/Zustand?
  → State machine: models transitions and guards (WHAT is allowed)
  → Redux/Zustand: stores and retrieves state (WHERE it lives)
  → They compose: state machine drives transitions, store persists the result

State machine vs useReducer?
  → useReducer: switch on event type, no explicit transition table
  → State machine: explicit valid transitions per state — guards included
  → Prefer state machine when invalid transitions are a real risk

Example

Order lifecycle as a finite state machine.

// States and events
type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
type OrderEvent  = 'CONFIRM' | 'SHIP' | 'DELIVER' | 'CANCEL';

// Transition table — source of truth for what's allowed
const ORDER_TRANSITIONS: Record<OrderStatus, Partial<Record<OrderEvent, OrderStatus>>> = {
  pending:   { CONFIRM: 'confirmed', CANCEL: 'cancelled' },
  confirmed: { SHIP: 'shipped',      CANCEL: 'cancelled' },
  shipped:   { DELIVER: 'delivered' },
  delivered: {},
  cancelled: {},
};

class OrderStateMachine {
  constructor(private _status: OrderStatus) {}

  get status(): OrderStatus { return this._status; }

  send(event: OrderEvent): void {
    const next = ORDER_TRANSITIONS[this._status]?.[event];
    if (!next) {
      throw new Error(`Cannot ${event} an order in '${this._status}' state`);
    }
    this._status = next;
  }

  can(event: OrderEvent): boolean {
    return event in (ORDER_TRANSITIONS[this._status] ?? {});
  }
}

// Usage
const order = new OrderStateMachine('pending');
order.can('CONFIRM');  // true
order.can('DELIVER');  // false — not yet shipped

order.send('CONFIRM'); // 'pending' → 'confirmed'
order.send('DELIVER'); // throws: "Cannot DELIVER an order in 'confirmed' state"

Edge Cases

Hierarchical states: Some states contain sub-states (loading can be loading.uploading or loading.processing). Nest state machines or use XState’s hierarchical states. Avoid nesting more than two levels deep.

Parallel states: Two independent state machines running simultaneously (e.g., document editor: saving state AND connectionStatus state). Model as two separate machines; avoid conflating them.

Persisting state: When restoring a machine from storage (DB, session), validate that the stored state is a valid state value before loading — never trust raw stored strings without validation.

Testing state machines: Test the transition table explicitly: valid transitions succeed, invalid transitions throw, guards prevent transitions when conditions fail. Tests should enumerate every valid and invalid transition.

describe('OrderStateMachine', () => {
  it('allows CONFIRM from pending', () => {
    const m = new OrderStateMachine('pending');
    m.send('CONFIRM');
    expect(m.status).toBe('confirmed');
  });

  it('rejects DELIVER from pending', () => {
    const m = new OrderStateMachine('pending');
    expect(() => m.send('DELIVER')).toThrow();
  });
});

Resources

  • circuit-breaker-pattern — circuit breaker is itself a 3-state machine (CLOSED/OPEN/HALF_OPEN)
  • result-pattern — composable error handling; complements state machine transitions
  • redux-toolkit — state storage that pairs well with state machine transitions