Every non-trivial application has state machines hiding in it. An order goes from pending to paid to shipped to delivered. A document goes from draft to review to approved to published. A connection goes from disconnected to connecting to connected to error.

Most developers manage these transitions with if-else chains and boolean flags. It works until it doesn’t — and when it doesn’t, the bugs are subtle, hard to reproduce, and scattered across the codebase.

The If-Else State Machine

Here’s what state management typically looks like in the wild:

class Order {
  status: "pending" | "paid" | "shipped" | "delivered" | "cancelled";

  cancel(): void {
    if (this.status === "pending" || this.status === "paid") {
      this.status = "cancelled";
      this.refundIfPaid();
    } else if (this.status === "shipped") {
      throw new Error("Cannot cancel a shipped order — initiate a return instead");
    } else if (this.status === "delivered") {
      throw new Error("Cannot cancel a delivered order");
    } else if (this.status === "cancelled") {
      // Already cancelled, ignore? Throw? Who knows?
    }
  }

  ship(): void {
    if (this.status === "paid") {
      this.status = "shipped";
      this.generateTrackingNumber();
      this.notifyCustomer();
    } else if (this.status === "pending") {
      throw new Error("Cannot ship an unpaid order");
    } else if (this.status === "shipped") {
      throw new Error("Already shipped");
    }
    // What about "cancelled"? Forgot to handle it.
  }

  // Every method has the same pattern: check status, branch, maybe forget a case
}

Problems:

  • Every method duplicates state-checking logic
  • Easy to forget a state (the cancelled case in ship())
  • Transition rules are scattered across methods
  • Adding a new state means editing every method
  • No clear picture of which transitions are valid

The State Pattern

The State pattern encapsulates each state as its own class. Each state knows what actions it supports and what the next state should be:

interface OrderState {
  readonly name: string;
  pay(order: Order): OrderState;
  ship(order: Order): OrderState;
  deliver(order: Order): OrderState;
  cancel(order: Order): OrderState;
}

class PendingState implements OrderState {
  readonly name = "pending";

  pay(order: Order): OrderState {
    order.recordPayment();
    return new PaidState();
  }

  ship(_order: Order): OrderState {
    throw new Error("Cannot ship an unpaid order");
  }

  deliver(_order: Order): OrderState {
    throw new Error("Cannot deliver an unpaid order");
  }

  cancel(order: Order): OrderState {
    order.recordCancellation("Cancelled before payment");
    return new CancelledState();
  }
}

class PaidState implements OrderState {
  readonly name = "paid";

  pay(_order: Order): OrderState {
    throw new Error("Order is already paid");
  }

  ship(order: Order): OrderState {
    order.generateTrackingNumber();
    order.notifyCustomer("Your order has shipped!");
    return new ShippedState();
  }

  deliver(_order: Order): OrderState {
    throw new Error("Cannot deliver before shipping");
  }

  cancel(order: Order): OrderState {
    order.processRefund();
    order.recordCancellation("Cancelled after payment — refund issued");
    return new CancelledState();
  }
}

class ShippedState implements OrderState {
  readonly name = "shipped";

  pay(_order: Order): OrderState {
    throw new Error("Order is already paid");
  }

  ship(_order: Order): OrderState {
    throw new Error("Order is already shipped");
  }

  deliver(order: Order): OrderState {
    order.notifyCustomer("Your order has been delivered!");
    return new DeliveredState();
  }

  cancel(_order: Order): OrderState {
    throw new Error("Cannot cancel a shipped order — initiate a return instead");
  }
}

class DeliveredState implements OrderState {
  readonly name = "delivered";

  pay(): OrderState { throw new Error("Cannot pay for a delivered order"); }
  ship(): OrderState { throw new Error("Cannot ship a delivered order"); }
  deliver(): OrderState { throw new Error("Order is already delivered"); }
  cancel(): OrderState { throw new Error("Cannot cancel a delivered order"); }
}

class CancelledState implements OrderState {
  readonly name = "cancelled";

  pay(): OrderState { throw new Error("Cannot pay for a cancelled order"); }
  ship(): OrderState { throw new Error("Cannot ship a cancelled order"); }
  deliver(): OrderState { throw new Error("Cannot deliver a cancelled order"); }
  cancel(): OrderState { throw new Error("Order is already cancelled"); }
}

The Order class delegates to its current state:

class Order {
  private state: OrderState = new PendingState();

  get status(): string {
    return this.state.name;
  }

  pay(): void {
    this.state = this.state.pay(this);
  }

  ship(): void {
    this.state = this.state.ship(this);
  }

  deliver(): void {
    this.state = this.state.deliver(this);
  }

  cancel(): void {
    this.state = this.state.cancel(this);
  }

  // Side-effect methods called by state objects
  recordPayment(): void { /* ... */ }
  generateTrackingNumber(): void { /* ... */ }
  notifyCustomer(message: string): void { /* ... */ }
  processRefund(): void { /* ... */ }
  recordCancellation(reason: string): void { /* ... */ }
}

Now:

  • Every valid transition is explicit
  • Every invalid transition throws a clear error
  • Adding a new state is one new class — existing states don’t change
  • You can see the state machine by reading the state classes

The Lightweight Alternative: Transition Table

Full state classes are powerful but verbose. For simpler machines, a transition table is cleaner:

type OrderStatus = "pending" | "paid" | "shipped" | "delivered" | "cancelled";
type OrderAction = "pay" | "ship" | "deliver" | "cancel";

const transitions: Record<OrderStatus, Partial<Record<OrderAction, OrderStatus>>> = {
  pending:   { pay: "paid", cancel: "cancelled" },
  paid:      { ship: "shipped", cancel: "cancelled" },
  shipped:   { deliver: "delivered" },
  delivered: {},
  cancelled: {},
};

class Order {
  private status: OrderStatus = "pending";

  transition(action: OrderAction): void {
    const nextStatus = transitions[this.status]?.[action];
    if (!nextStatus) {
      throw new Error(`Cannot ${action} an order in ${this.status} status`);
    }
    this.status = nextStatus;
  }
}

const order = new Order();
order.transition("pay");     // pending → paid
order.transition("ship");    // paid → shipped
order.transition("cancel");  // Error: Cannot cancel an order in shipped status

The entire state machine is visible in one object. You can validate it, visualize it, or generate documentation from it.

Python Implementation

from enum import Enum, auto
from typing import Callable

class DocumentStatus(Enum):
    DRAFT = auto()
    REVIEW = auto()
    APPROVED = auto()
    PUBLISHED = auto()
    ARCHIVED = auto()

class DocumentAction(Enum):
    SUBMIT = auto()
    APPROVE = auto()
    REJECT = auto()
    PUBLISH = auto()
    ARCHIVE = auto()

# Transition table: (current_state, action) → next_state
TRANSITIONS: dict[tuple[DocumentStatus, DocumentAction], DocumentStatus] = {
    (DocumentStatus.DRAFT, DocumentAction.SUBMIT): DocumentStatus.REVIEW,
    (DocumentStatus.REVIEW, DocumentAction.APPROVE): DocumentStatus.APPROVED,
    (DocumentStatus.REVIEW, DocumentAction.REJECT): DocumentStatus.DRAFT,
    (DocumentStatus.APPROVED, DocumentAction.PUBLISH): DocumentStatus.PUBLISHED,
    (DocumentStatus.PUBLISHED, DocumentAction.ARCHIVE): DocumentStatus.ARCHIVED,
}

class Document:
    def __init__(self, title: str):
        self.title = title
        self.status = DocumentStatus.DRAFT
        self._on_transition: list[Callable] = []

    def on_transition(self, callback: Callable) -> None:
        self._on_transition.append(callback)

    def perform(self, action: DocumentAction) -> None:
        key = (self.status, action)
        next_status = TRANSITIONS.get(key)
        if next_status is None:
            raise ValueError(
                f"Cannot {action.name.lower()} a document in {self.status.name.lower()} status"
            )
        old_status = self.status
        self.status = next_status

        for callback in self._on_transition:
            callback(self, old_status, next_status, action)

# Usage:
doc = Document("Architecture RFC")
doc.on_transition(lambda d, old, new, action:
    print(f"'{d.title}': {old.name}{new.name} via {action.name}")
)

doc.perform(DocumentAction.SUBMIT)   # DRAFT → REVIEW
doc.perform(DocumentAction.APPROVE)  # REVIEW → APPROVED
doc.perform(DocumentAction.PUBLISH)  # APPROVED → PUBLISHED
doc.perform(DocumentAction.REJECT)   # ValueError!

Guards and Side Effects

Real state machines often have conditions (guards) and side effects:

interface Transition<S, A> {
  from: S;
  action: A;
  to: S;
  guard?: () => boolean;
  onTransition?: () => void;
}

const transitions: Transition<OrderStatus, OrderAction>[] = [
  {
    from: "pending",
    action: "pay",
    to: "paid",
    guard: () => paymentGateway.isAvailable(),
    onTransition: () => notifier.send("Payment received!"),
  },
  {
    from: "paid",
    action: "ship",
    to: "shipped",
    guard: () => inventory.isInStock(),
    onTransition: () => {
      trackingService.generate();
      notifier.send("Your order has shipped!");
    },
  },
  // ...
];

function executeTransition(
  current: OrderStatus,
  action: OrderAction,
): OrderStatus {
  const transition = transitions.find(
    t => t.from === current && t.action === action,
  );

  if (!transition) {
    throw new Error(`No transition for ${action} from ${current}`);
  }

  if (transition.guard && !transition.guard()) {
    throw new Error(`Guard condition failed for ${action}`);
  }

  transition.onTransition?.();
  return transition.to;
}

When to Use State Machines

Use the full State pattern when:

  • Each state has complex, distinct behavior (different methods do different things)
  • States have their own data and internal logic
  • You want compile-time safety on state transitions
  • The state machine is central to your domain

Use a transition table when:

  • Transitions are the main concern (not per-state behavior)
  • The machine is relatively simple (< 10 states)
  • You want the machine to be visible in one place
  • You might need to serialize/visualize the machine

Use a library (XState, etc.) when:

  • You need hierarchical/nested states
  • Parallel state regions are required
  • You want visual state machine editors
  • The machine drives complex UI flows

Just use if-else when:

  • There are only 2-3 states with simple transitions
  • The logic is unlikely to grow
  • Adding a pattern would be more code than the problem warrants

The Visualization Test

A well-designed state machine can be drawn as a diagram. If you can’t draw your state transitions on a whiteboard, your code is too tangled:

┌─────────┐   pay    ┌──────┐   ship   ┌─────────┐  deliver  ┌───────────┐
│ Pending  │────────►│ Paid  │────────►│ Shipped  │─────────►│ Delivered  │
└─────────┘         └──────┘         └─────────┘          └───────────┘
     │                  │
     │ cancel           │ cancel
     ▼                  ▼
┌───────────┐    ┌───────────┐
│ Cancelled │◄───│ Cancelled │
└───────────┘    └───────────┘

If your diagram has arrows going everywhere with conditions scribbled on them, your state machine needs simplification. Good state machines are simple enough to explain to a non-technical person: “An order starts as pending, gets paid, then shipped, then delivered. You can cancel it before it ships.”

That clarity in the diagram should match the clarity in your code. The State pattern ensures it does.