Behavioral Pattern (GoF)

State

Lets an object alter its behavior when its internal state changes — as if the object changed class at runtime. Each possible state is encapsulated in its own class, and the transitions are part of the design.

Intent

Encapsulate each possible state of an object in its own class and delegate state-dependent behavior to the current state object. From the context's point of view, the object seems to "change class" when it changes state — but in reality it swaps the state object that holds the behavior.

Cataloged by the GoF (1994) as a behavioral pattern, State is the elegant solution for finite state machines: it replaces long conditionals (switch or if/else) that check the current state with a structure where each state knows its own valid behaviors and its allowed transitions.

Problem

Consider the workflow of an editorial document: draft, in review, and published. Each state allows different actions: a draft can be sent for review, but not published directly; a document in review can be approved (moves to published) or rejected (goes back to draft); a published document can't go backwards.

The naive approach encodes all these rules in conditionals inside the context:

// Naive approach — DON'T do this:
class Document {
  private state: string = "draft";

  publish(): void {
    if (this.state === "draft") {
      throw new Error("A draft cannot be published directly.");
    } else if (this.state === "review") {
      this.state = "published";
      console.log("Document published.");
    } else if (this.state === "published") {
      throw new Error("Already published.");
    }
  }

  sendForReview(): void {
    if (this.state === "draft") {
      this.state = "review";
    } else {
      throw new Error("Only drafts can go to review.");
    }
  }
  // Every new state or action grows all the if/else above.
}

Adding a new state (e.g.: archived) requires opening every method and adding one more branch — a violation of the Open/Closed Principle. State solves this by extracting each state into its own class.

Solution

State organizes the code into three participants:

  1. Context: keeps a reference to the current state (DocumentState) and delegates operations to it. It exposes an internal method so states can request a state change.
  2. State (interface): declares the methods that represent state-dependent operations. All concrete states implement this interface.
  3. ConcreteState: implements the behavior corresponding to a specific state of the context and can trigger transitions by calling the state-change method on the context.

Key distinction: State vs Strategy

State and Strategy are structurally identical — both have a context that delegates to an interchangeable object implementing a common interface. The difference lies in intent and who controls the transitions:

  • Strategy: the client chooses and injects the algorithm. The context doesn't know which strategy is active and doesn't care — there are no automatic transitions. Algorithms don't know each other.
  • State: transitions are part of the design and happen automatically. States know each other: a state knows which other states it can transition to. The client rarely swaps the state directly — the current state does that in response to events.

Structure

         «interface»
        DocumentState
  ┌────────────────────────────────────────┐
  │ + publish(ctx: Document): void         │
  │ + sendForReview(ctx): void             │
  │ + reject(ctx: Document): void          │
  └────────────────────────────────────────┘
              ▲
   ┌──────────┼────────────────────────┐
   │          │                        │
DraftState   ReviewState        PublishedState
(Concrete)   (Concrete)         (Concrete)
  send OK    publish OK          all: error
  publish err reject OK


         Document (Context)
  ┌────────────────────────────────────────┐
  │ - state: DocumentState                 │
  │ + publish(): void                      │
  │ + sendForReview(): void                │
  │ + reject(): void                       │
  │ + transitionTo(s): void [pkg]          │
  └────────────────────────────────────────┘
              │ delegates to
              ▼
        DocumentState (current)


Transition flow:

  doc.sendForReview()
    → DraftState.sendForReview(doc)
      → doc.transitionTo(new ReviewState())

  doc.publish()
    → ReviewState.publish(doc)
      → doc.transitionTo(new PublishedState())

Code examples

Example 1 — Document workflow (draft → review → published)

A complete implementation with a state interface, concrete states that control their own transitions, and a context that delegates without conditionals.

// ── State interface ───────────────────────────────────────────
interface DocumentState {
  publish(doc: Document): void;
  sendForReview(doc: Document): void;
  reject(doc: Document): void;
  name(): string;
}

// ── Context ───────────────────────────────────────────────────
class Document {
  private state: DocumentState;

  constructor(private readonly title: string) {
    this.state = new DraftState();
  }

  // Called by concrete states — not by the client directly.
  transitionTo(newState: DocumentState): void {
    console.log(`  [${this.title}] ${this.state.name()} -> ${newState.name()}`);
    this.state = newState;
  }

  publish(): void        { this.state.publish(this); }
  sendForReview(): void  { this.state.sendForReview(this); }
  reject(): void         { this.state.reject(this); }

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

// ── ConcreteStates ────────────────────────────────────────────
class DraftState implements DocumentState {
  publish(doc: Document): void {
    throw new Error("A draft cannot be published without review.");
  }
  sendForReview(doc: Document): void {
    doc.transitionTo(new ReviewState());
  }
  reject(doc: Document): void {
    throw new Error("A draft cannot be rejected.");
  }
  name(): string { return "Draft"; }
}

class ReviewState implements DocumentState {
  publish(doc: Document): void {
    doc.transitionTo(new PublishedState());
  }
  sendForReview(doc: Document): void {
    throw new Error("Already in review.");
  }
  reject(doc: Document): void {
    doc.transitionTo(new DraftState());
  }
  name(): string { return "In Review"; }
}

class PublishedState implements DocumentState {
  publish(doc: Document): void {
    throw new Error("Already published.");
  }
  sendForReview(doc: Document): void {
    throw new Error("A published document cannot go back to review.");
  }
  reject(doc: Document): void {
    throw new Error("A published document cannot be rejected.");
  }
  name(): string { return "Published"; }
}

// ── Usage ────────────────────────────────────────────────────
const doc = new Document("Design Patterns Guide");

doc.sendForReview();
// [Design Patterns Guide] Draft -> In Review

doc.reject();
// [Design Patterns Guide] In Review -> Draft

doc.sendForReview();
// [Design Patterns Guide] Draft -> In Review

doc.publish();
// [Design Patterns Guide] In Review -> Published

console.log("Final state:", doc.getStateName());
// Final state: Published

try {
  doc.publish(); // already published
} catch (e) {
  console.log("Expected error:", (e as Error).message);
  // Expected error: Already published.
}

Example 2 — Singleton state: shared state objects

When states have no data of their own (they're stateless), they can be shared as singletons — saving allocations. This is an optimization mentioned by the GoF and illustrates the relationship between State and Singleton.

// Stateless states can be shared — a single instance per type.
// Here we use typed object literals (lighter than classes in TS for simple cases).

interface PlayerState {
  play(player: MediaPlayer): void;
  pause(player: MediaPlayer): void;
  stop(player: MediaPlayer): void;
  name(): string;
}

// Singleton state as a typed object literal.
const Stopped: PlayerState = {
  play(player) { player.transitionTo(Playing); },
  pause(_p)    { /* ignore — already stopped */ },
  stop(_p)     { /* already stopped */ },
  name()       { return "Stopped"; },
};

const Playing: PlayerState = {
  play(_p)     { /* already playing */ },
  pause(player){ player.transitionTo(Paused); },
  stop(player) { player.transitionTo(Stopped); },
  name()       { return "Playing"; },
};

const Paused: PlayerState = {
  play(player) { player.transitionTo(Playing); },
  pause(_p)    { /* already paused */ },
  stop(player) { player.transitionTo(Stopped); },
  name()       { return "Paused"; },
};

class MediaPlayer {
  private state: PlayerState = Stopped;

  transitionTo(newState: PlayerState): void {
    console.log(`  ${this.state.name()} -> ${newState.name()}`);
    this.state = newState;
  }

  play():  void { this.state.play(this); }
  pause(): void { this.state.pause(this); }
  stop():  void { this.state.stop(this); }

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

// ── Usage ────────────────────────────────────────────────────
const player = new MediaPlayer();
console.log("Start:", player.getState()); // Start: Stopped

player.play();   //   Stopped -> Playing
player.pause();  //   Playing -> Paused
player.play();   //   Paused -> Playing
player.stop();   //   Playing -> Stopped
console.log("End:", player.getState()); // End: Stopped

When to use

  • When an object's behavior radically changes depending on its state and there are many possible states: workflows (order, document, ticket), media players, network connections (connected/disconnected/reconnecting), communication ports.
  • When state transitions are complex and rule-driven: each state knows which other states it can come from and go to. This becomes clear and testable when encapsulated in state classes.
  • To eliminate state conditionals scattered across several methods: if you have if (this.state === "x") repeated in dozens of methods, State extracts that logic to where it belongs — inside each state.

When to avoid

  • When there are only two or three simple, stable states: an enum with a direct conditional is more readable than building the entire State class hierarchy for a trivial machine.
  • When behavior doesn't change per state: if every state runs the same methods with the same logic, the pattern adds no value — you're creating empty classes.

Pros and cons

Pros

  • Eliminates large state conditionals scattered across the context's code.
  • Each state is an isolated class — easy to test, understand, and modify without affecting other states.
  • New states can be added without changing the context or existing states (Open/Closed Principle).
  • Explicit transitions inside each state make the flow readable and auditable.
  • Stateless states can be shared as singletons (memory optimization).

Cons

  • Class explosion for state machines with many states — each state becomes a class.
  • The global view of transitions gets spread across state classes — harder to see the whole diagram in one place.
  • For simple machines, the class overhead is unnecessary — a transition table or an enum may be enough.

Common pitfalls

1. Where to put the transition logic

The trickiest decision: who triggers the state transition — the Context or the ConcreteState? The GoF recommends that the states themselves trigger transitions, since they know the rules of when and where to go. This distributes transition knowledge to each state, making each one independent. The risk: states that reference each other mutually create circular coupling. To avoid that, pass the Context to the state (as shown in the examples) instead of instantiating states directly inside other states when possible.

2. Class explosion for simple machines

Rule of thumb: if the machine has fewer than four states and the transitions are simple, consider a lighter approach: a state enum plus a transition method with a centralized switch in the Context. Reserve the full State pattern for machines where per-state behavior is substantially different and transitions have meaningful logic.

3. States with data vs stateless states

If a state needs to store its own data (e.g.: a retry counter in a "reconnecting" state), it can't be shared as a singleton — each Context instance needs its own state instance. Stateless states are the exception, not the rule. Try to keep the context's data in the Context and leave states as pure behavior and transition logic.

4. Confusing State with Strategy

Structurally identical, but the difference in intent matters in practice. If you're modeling automatic transitions and the "objects" know each other to transition between one another — it's State. If the client chooses and injects the behavior externally and there are no transitions between "states" — it's Strategy. The diagnostic question: "who decides the next variation — the object itself or the external client?". If the object itself decides, it's State.

Related patterns

State interacts with other behavioral and creational patterns:

Strategy is State's "structural sibling": both encapsulate variable behavior in interchangeable objects, but Strategy is controlled externally by the client (no automatic transitions) while State controls its own transitions. Singleton is used as an optimization when states are stateless — each concrete state without its own data can be shared as a single instance across all contexts. Observer can be combined with State to notify external components about state transitions — the Context acts as a Subject and notifies Observers when the state changes.