StackPractices
intermediate By Mathias Paulenko

State Pattern

Allow an object to alter its behavior when its internal state changes. The GoF behavioral pattern for finite state machines.

Topics: design

Overview

Your Order class starts small: pay(), ship(), cancel(). Then each method grows a switch (status). Pay is only valid while pending, shipping only after payment, cancel except when shipped. Six months later the same switch is copy-pasted across every method, and adding a refunded state means auditing all of them.

The State pattern — one of the 11 behavioral patterns in the Gang of Four’s Design Patterns (Gamma et al., 1994) — is the classic fix: each state becomes its own class, the context delegates calls to the current state object, and the states themselves decide which transitions are legal. Adding a state means adding a class, not re-reading every conditional.

Two quick clarifications before diving in:

  • This is the GoF object pattern: a way to model a finite state machine with classes. It’s not about UI state management: for React components and XState-style statecharts, see the State Machine UI Pattern.
  • If your state machine has two or three states with trivial behavior, a plain switch or enum is fine. The pattern earns its keep when transitions and per-state logic get complex.

When to Use

Use the State Pattern when:

  • An object’s behavior depends on its state and must change at runtime: an Order that can ship only after payment, a Connection that behaves differently open vs closed, a media player that ignores play() while stopped.
  • The same status check is copy-pasted across methods: the classic sign that a state machine is leaking conditionals everywhere.
  • States have transitions with side effects: entry/exit actions like logging, notifications, or resource cleanup.
  • New states appear regularly, and each should land as a new class without touching the existing ones (open/closed principle).

Don’t use it when the state machine is degenerate (two states, no transitions with behavior) or when a data-driven transition table (see Variants) already covers your needs. A switch on an enum is a legitimate choice; it rots when that same switch exists in five methods.

Problem

This is the shape the pattern removes: every method repeating the same state checks:

class Order:
    def __init__(self):
        self.status = "pending"

    def pay(self):
        if self.status == "pending":
            print("Processing payment...")
            self.status = "paid"
        else:
            print(f"Cannot pay in state {self.status}")

    def ship(self):
        if self.status == "paid":
            print("Shipping order...")
            self.status = "shipped"
        else:
            print(f"Cannot ship in state {self.status}")

    # cancel() repeats the same if/elif ladder,
    # and "refunded" would have to be added to all three.

The bug surface grows with every state: transitions are validated in three places, error messages drift apart, and refunded requires editing every method. The State pattern moves each if branch into a state class where it lives exactly once.

Solution

The same Order rebuilt around state objects: each state class owns its behavior and its outbound transitions:

Order state machine — Pending transitions to Paid on pay, Paid transitions to Shipped on ship, Pending and Paid transition to Cancelled on cancel, Shipped and Cancelled are terminal states

Python

from abc import ABC, abstractmethod

class OrderState(ABC):
    @abstractmethod
    def pay(self, order):
        pass

    @abstractmethod
    def ship(self, order):
        pass

    @abstractmethod
    def cancel(self, order):
        pass

class PendingState(OrderState):
    def pay(self, order):
        print("Processing payment...")
        order.transition_to(PaidState())
    def ship(self, order):
        print("Cannot ship: payment pending")
    def cancel(self, order):
        print("Order cancelled")
        order.transition_to(CancelledState())

class PaidState(OrderState):
    def pay(self, order):
        print("Already paid")
    def ship(self, order):
        print("Shipping order...")
        order.transition_to(ShippedState())
    def cancel(self, order):
        print("Issuing refund...")
        order.transition_to(CancelledState())

class ShippedState(OrderState):
    def pay(self, order):
        print("Already paid")
    def ship(self, order):
        print("Already shipped")
    def cancel(self, order):
        print("Cannot cancel: already shipped")

class CancelledState(OrderState):
    def pay(self, order):
        print("Cannot pay: order cancelled")
    def ship(self, order):
        print("Cannot ship: order cancelled")
    def cancel(self, order):
        print("Already cancelled")

class Order:
    def __init__(self):
        self._state = PendingState()

    def transition_to(self, state):
        self._state = state

    def pay(self):
        self._state.pay(self)

    def ship(self):
        self._state.ship(self)

    def cancel(self):
        self._state.cancel(self)

# Usage
order = Order()
order.pay()
order.ship()
order.cancel()  # Cannot cancel: already shipped

JavaScript

class Order {
  constructor() { this.state = new PendingState(this); }
  transitionTo(state) { this.state = state; }
  pay() { this.state.pay(); }
  ship() { this.state.ship(); }
  cancel() { this.state.cancel(); }
}

class PendingState {
  constructor(order) { this.order = order; }
  pay() {
    console.log("Processing payment...");
    this.order.transitionTo(new PaidState(this.order));
  }
  ship() { console.log("Cannot ship: payment pending"); }
  cancel() {
    console.log("Order cancelled");
    this.order.transitionTo(new CancelledState(this.order));
  }
}

class PaidState {
  constructor(order) { this.order = order; }
  pay() { console.log("Already paid"); }
  ship() {
    console.log("Shipping order...");
    this.order.transitionTo(new ShippedState(this.order));
  }
  cancel() {
    console.log("Issuing refund...");
    this.order.transitionTo(new CancelledState(this.order));
  }
}

class ShippedState {
  constructor(order) { this.order = order; }
  pay() { console.log("Already paid"); }
  ship() { console.log("Already shipped"); }
  cancel() { console.log("Cannot cancel: already shipped"); }
}

class CancelledState {
  constructor(order) { this.order = order; }
  pay() { console.log("Cannot pay: order cancelled"); }
  ship() { console.log("Cannot ship: order cancelled"); }
  cancel() { console.log("Already cancelled"); }
}

const order = new Order();
order.pay();
order.ship();
order.cancel(); // Cannot cancel: already shipped

Java

interface OrderState {
    void pay(Order order);
    void ship(Order order);
    void cancel(Order order);
}

class PendingState implements OrderState {
    public void pay(Order order) {
        System.out.println("Processing payment...");
        order.transitionTo(new PaidState());
    }
    public void ship(Order order) {
        System.out.println("Cannot ship: payment pending");
    }
    public void cancel(Order order) {
        System.out.println("Order cancelled");
        order.transitionTo(new CancelledState());
    }
}

class PaidState implements OrderState {
    public void pay(Order order) {
        System.out.println("Already paid");
    }
    public void ship(Order order) {
        System.out.println("Shipping order...");
        order.transitionTo(new ShippedState());
    }
    public void cancel(Order order) {
        System.out.println("Issuing refund...");
        order.transitionTo(new CancelledState());
    }
}

class ShippedState implements OrderState {
    public void pay(Order order) {
        System.out.println("Already paid");
    }
    public void ship(Order order) {
        System.out.println("Already shipped");
    }
    public void cancel(Order order) {
        System.out.println("Cannot cancel: already shipped");
    }
}

class CancelledState implements OrderState {
    public void pay(Order order) {
        System.out.println("Cannot pay: order cancelled");
    }
    public void ship(Order order) {
        System.out.println("Cannot ship: order cancelled");
    }
    public void cancel(Order order) {
        System.out.println("Already cancelled");
    }
}

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

    public void transitionTo(OrderState state) {
        this.state = state;
    }

    public void pay() { state.pay(this); }
    public void ship() { state.ship(this); }
    public void cancel() { state.cancel(this); }
}

// Usage
Order order = new Order();
order.pay();
order.ship();
order.cancel(); // Cannot cancel: already shipped

Explanation

The pattern has three roles:

  • Context (Order): holds a reference to the current state and delegates every state-dependent call to it. It knows nothing about which transitions are legal.
  • State interface (OrderState): the contract every state fulfills: one method per event the machine responds to.
  • Concrete states (PendingState, PaidState, ShippedState, CancelledState): implement the behavior for their state and perform transitions by swapping the context’s state reference.

The GoF book calls this “Objects for States” and its canonical example is TCPConnection: a socket object whose open(), acknowledge(), and close() all behave differently depending on whether its state is Listen, Established, or Closed. Same idea as our Order, at protocol level.

Who owns the transitions?

The classic GoF version (shown above) lets states decide the next state. It’s honest about trade-offs:

  • State-owned (this example): each state class knows its successors. Transitions live next to the behavior, but concrete states are coupled to each other; PendingState can’t compile without PaidState.
  • Context-owned: the Order consults a transition table and states never reference each other. States become reusable across machines, but the context regains conditional logic, which is what we were escaping.
  • Table-driven: transitions live in data (transitions[state][event] = next), not classes. Best when you’ve got many states with sparse behavior (see Variants).

Entry and exit actions

When transitions need side effects (audit logs, metrics, releasing a seat), put them in enter()/exit() hooks on the state interface, and have transition_to call them:

def transition_to(self, state):
    if self._state:
        self._state.exit(self)
    self._state = state
    self._state.enter(self)

enter() runs after the swap, exit() before. This is where a pending order logs “left pending after 3 days” or a paid state triggers the “payment confirmed” email: effects that belong to the transition, not to whoever called pay().

Variants

VariantDescriptionUse Case
Simple State MachineStates handle transitionsSmall number of states
Hierarchical State MachineStates can have substatesComplex nested behavior
State TableTransitions stored in a lookup tableMany states with predictable transitions
Pushdown AutomatonStack-based state historyUndo/reversible state transitions. See Memento

For the table-driven variant, the whole machine collapses into a dict; worth it once you pass roughly six states and the per-state logic stays thin:

TRANSITIONS = {
    ("pending", "pay"): "paid",
    ("paid", "ship"): "shipped",
    ("pending", "cancel"): "cancelled",
    ("paid", "cancel"): "cancelled",
}

class InvalidTransition(Exception):
    pass

class Order:
    def __init__(self):
        self.state = "pending"

    def handle(self, event):
        next_state = TRANSITIONS.get((self.state, event))
        if next_state is None:
            raise InvalidTransition(f"{event} not allowed in {self.state}")
        self.state = next_state

This version keeps the legality matrix in one readable place (you can even render it in docs), but you lose the per-state hooks and have to attach side effects elsewhere. For genuinely large machines, libraries like transitions (Python), xstate (TypeScript), or Spring StateMachine do this for you and add guards, hierarchies, and visualization.

Testing State Machines

The pattern pays for itself at test time: each state is a unit, and transitions are assertions on the context:

def test_pending_order_becomes_paid():
    order = Order()
    order.pay()
    assert isinstance(order._state, PaidState)

def test_cannot_ship_before_payment():
    order = Order()
    order.ship()
    assert isinstance(order._state, PendingState)

def test_cancelled_order_rejects_everything():
    order = Order()
    order.cancel()
    order.pay()
    assert isinstance(order._state, CancelledState)

Test the matrix, not the wiring: one test per (state, event) pair that matters, plus the terminal states. The InvalidTransition path in the table variant is a good property-based test candidate: any pair outside TRANSITIONS must raise.

What Works

  • Delegate all state-dependent behavior to state objects, keeping the context thin
  • Make state objects immutable or recreate them on transition to avoid shared state bugs
  • Use enums for well-defined states in languages that support them (Java, TypeScript)
  • Add entry/exit hooks when transitions need side effects (logging, analytics)
  • Consider a transition table once you’ve got many states and transitions become hard to manage

Common Mistakes

  • Silent invalid transitions. Returning null or logging quietly on an illegal event hides bugs; raise InvalidTransition or at least make the no-op loud.
  • Sharing a single state instance across contexts. If PaidState holds mutable fields, two orders share bugs. Instantiate per order or keep states stateless.
  • Business logic inside the context. Order growing if state == "paid": apply_discount() is the original problem creeping back: the discount belongs to PaidState or a collaborator.
  • States calling back into unrelated context methods. If a state starts needing order.user.email and order.inventory.reserve(), that’s a sign you need the Mediator or a proper domain service, not deeper coupling.
  • A context that exposes transition_to publicly. Callers doing order.transition_to(ShippedState()) bypass every rule; keep transitions triggered only through events. (In the examples above, transition_to exists for the states’ internal use; think of it as plumbing, not public API. _transition_to works too.)

See Also

  • Command Pattern — encapsulates requests as objects; pairs naturally with State when transitions trigger commands
  • Observer Pattern — for notifying listeners when a transition happens
  • Memento Pattern — snapshots state for undo; complements the pushdown-automaton variant
  • State Machine UI Pattern — the frontend sibling: finite state machines and statecharts for UI components
  • Companion repository — the order state machine as runnable Python, JavaScript, and Java files, including the table-driven variant

Further Reading

Frequently Asked Questions

What is the difference between State and Strategy?

Both encapsulate behavior in interchangeable objects, but the mechanics differ. State changes what the object does as its internal state moves through a lifecycle. Nobody picks anything. Strategy works the other way around: the caller picks the algorithm from outside, and the object doesn't care which one it got.

Should states know about each other?

In the classic implementation, states trigger transitions to other states. An alternative is to let the context or a transition table manage transitions, keeping states decoupled.

Can I use enums instead of classes for simple state machines?

Yes. For the simple case, an enum plus a switch does the job. Reach for the full pattern when states carry real behavior or you expect to keep adding them; an enum can't grow an enter() hook.

How does this relate to finite state machines and XState?

The GoF pattern is one way to implement a finite state machine with classes: states as objects, events as method calls, transitions as object swaps. Front-end tools like XState take the same concept further with statecharts (hierarchies, parallel states, visual editing), and the State Machine UI Pattern covers that side. If your order has nested states like shipped.in_transit and shipped.delivered, a statechart library is usually the better fit than hand-rolled classes.

How do I add a new state without breaking existing ones?

Create the class, implement the three events, and wire the inbound transition from the state that produces it (e.g., a RefundedState reachable from PaidState.cancel()). Nothing else needs recompiling, which is exactly the point: the switch version would have forced you through every method.

Is a state ever allowed to call back into the context?

Yes — the context reference is the only channel a state has for triggering a transition. The smell is when it calls beyond transition_to and the state-specific helpers: reaching for order.payment_gateway from inside PaidState means the state knows too much about the context's internals. Pass the context but treat it as a handle for transitions, not as a grab-bag of services.