State Pattern
Solution: State Objects with Behavior
Read aloud is unavailable in this browser.
The Elegant Transformation
In the previous page, we witnessed how conditional state logic transforms clean objects into maintenance nightmares. Every method becomes a sprawling switch statement, every new state requires editing every method, and the code resists testing, understanding, and evolution.
The State Pattern provides a fundamentally different approach: instead of asking "what state am I in?" before every action, we delegate to an object that embodies that state. The state knows its own behavior. The state knows which operations are valid. The state knows how to transition to other states.
This transformation is profound. We replace procedural "check and branch" logic with polymorphic delegation. We replace scattered state knowledge with encapsulated state objects. We replace modification with extension.
By the end of this page, you will understand the complete structure and mechanics of the State Pattern. You'll see how state interfaces define behavioral contracts, how concrete states implement those contracts, and how the context object delegates to whatever state is current. You'll walk through complete implementations that solve the vending machine and order processing examples from the previous page.
The Core Insight: State as Object
The State Pattern is built on a simple but powerful realization: a state is not just a value—it's an object with behavior.
In the conditional approach, state is typically an enum or integer:
private state: VendingMachineState = VendingMachineState.IDLE;
This representation treats state as inert data. The behavior lives elsewhere—scattered across switch statements that interpret what this value means.
The State Pattern inverts this relationship:
private state: VendingMachineState = new IdleState(this);
Now state is an object that carries its own behavior. When we want state-dependent behavior, we don't check a value—we ask the state object to act. The state knows how to behave because behavior is intrinsic to the state itself.
| Aspect | Conditional Approach | State Pattern Approach |
|---|---|---|
| State representation | Enum or integer value | Full object with methods |
| Behavior location | Scattered in switch statements | Encapsulated in state classes |
| Adding new state | Modify every method with conditionals | Add new class implementing state interface |
| Understanding a state | Read all methods, find all branches | Read single state class |
| Testing a state | Navigate through conditionals in every method | Test the state class directly |
| State knowledge distribution | Context knows everything about all states | Each state knows only itself |
The State Pattern is fundamentally about replacing conditional logic with polymorphism. Instead of 'if state == X, do this', you have state.doThis()—and X's implementation of doThis() does the right thing automatically. This is the same principle behind replacing type-checking with inheritance, applied to runtime state variation.
Pattern Structure and Participants
The State Pattern involves three key participants, each with a distinct role in the overall design. Understanding these roles is essential for proper pattern implementation.
- •Context — The object whose behavior varies by state. It maintains a reference to the current state object and delegates state-dependent behavior to it. The context often provides methods for changing state that concrete states can invoke.
- •State Interface — Defines the interface for encapsulating state-specific behavior. This interface declares methods corresponding to the context's state-dependent operations. All concrete states implement this interface.
- •Concrete States — Classes implementing the state interface, each providing behavior appropriate for that specific state. Each concrete state defines what happens when its operations are invoked AND may trigger transitions to other states.
The delegation mechanism:
When a client calls a method on the context (e.g., vendingMachine.insertCoin()), the context doesn't implement this logic directly. Instead, it delegates to its current state:
insertCoin(amount: number): void {
this.state.insertCoin(this, amount);
}
The state object receives the context as a parameter, enabling it to:
- Access context data needed for its operation
- Modify context data as part of its operation
- Change the context's current state (triggering a transition)
States typically need access to the context to perform their operations and trigger state transitions. This can be provided through method parameters (as shown) or by storing a context reference in the state object. Both approaches have trade-offs we'll explore later.
Implementing the State Interface
The state interface is the contract that all concrete states must fulfill. It mirrors the state-dependent operations of the context, ensuring that any state can handle any request that might be made.
Let's define a complete state interface for our vending machine example:
1234567891011121314151617181920212223242526272829303132333435363738394041
/** * Interface defining all state-dependent operations for three vending machine. * Each concrete state will implement this interface, providing behavior * appropriate for that state. */interface VendingMachineState { /** * Handle coin insertion in this state. * @returns Description of what happened for user feedback */ insertCoin(machine: VendingMachine, amount: number): string; /** * Handle product selection in this state. * @returns Description of what happened for user feedback */ selectProduct(machine: VendingMachine, productId: string): string; /** * Handle refund request in this state. * @returns Amount refunded (0 if none) */ refund(machine: VendingMachine): number; /** * Get the name of this state for logging/debugging. */ getName(): string; /** * Check if entering this state should trigger any actions. * Called when transitioning INTO this state. */ onEnter?(machine: VendingMachine): void; /** * Check if exiting this state should trigger any cleanup. * Called when transitioning OUT OF this state. */ onExit?(machine: VendingMachine): void;}Design decisions in the interface:
-
Return types communicate outcome — Methods return meaningful values rather than void, enabling the context to communicate results to callers.
-
Context passed as parameter — Each method receives the machine, providing access to shared data and state-changing capabilities.
-
Lifecycle hooks are optional — Not all states need entry/exit logic, so these methods use optional method notation.
-
getName() for debugging — A simple method returning the state's name aids logging, debugging, and user communication.
The state interface should include ALL operations that might have state-dependent behavior, even if some states handle them identically. This ensures states are interchangeable—the context can delegate to any state without knowing which concrete state it's dealing with.
Implementing Concrete States
Each concrete state class implements the state interface, providing behavior specific to that state. The implementation includes both the state's actions AND any transitions it triggers.
Let's implement all four states for our vending machine:
123456789101112131415161718192021222324252627282930
/** * State: Machine is waiting for coins. * Accepts coins, rejects product selection (no money), rejects refund (no money). */class IdleState implements VendingMachineState { getName(): string { return "IDLE"; } insertCoin(machine: VendingMachine, amount: number): string { // Accept the coin and transition to HAS_COINS state machine.addToBalance(amount); machine.setState(new HasCoinsState()); return `Coin accepted. Balance: ${machine.getBalance()} cents.`; } selectProduct(machine: VendingMachine, productId: string): string { // Cannot select without money return "Please insert coins first."; } refund(machine: VendingMachine): number { // Nothing to refund return 0; } onEnter(machine: VendingMachine): void { console.log("[State] Entering IDLE - ready for customers"); }}12345678910111213141516171819202122232425262728293031323334353637383940414243444546
/** * State: Machine has received coins, waiting for selection. * Accepts more coins, processes product selection, allows refund. */class HasCoinsState implements VendingMachineState { getName(): string { return "HAS_COINS"; } insertCoin(machine: VendingMachine, amount: number): string { // Accept additional coins, stay in this state machine.addToBalance(amount); return `Additional coin accepted. Balance: ${machine.getBalance()} cents.`; } selectProduct(machine: VendingMachine, productId: string): string { const price = machine.getPrice(productId); const stock = machine.getStock(productId); // Check stock if (stock === 0) { return `${productId} is out of stock. Please select another product.`; } // Check balance if (machine.getBalance() < price) { const needed = price - machine.getBalance(); return `Insufficient balance. Need ${needed} more cents for ${productId}.`; } // Sufficient balance and stock - dispense! machine.setState(new DispensingState(productId)); return `Dispensing ${productId}...`; } refund(machine: VendingMachine): number { const amount = machine.getBalance(); machine.setBalance(0); machine.setState(new IdleState()); return amount; } onEnter(machine: VendingMachine): void { console.log(`[State] Entering HAS_COINS - balance: ${machine.getBalance()}`); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
/** * State: Machine is actively dispensing a product. * Rejects all operations until dispensing completes. */class DispensingState implements VendingMachineState { private productId: string; constructor(productId: string) { this.productId = productId; } getName(): string { return "DISPENSING"; } insertCoin(machine: VendingMachine, amount: number): string { // Reject coins during dispensing return "Please wait, dispensing in progress. Coin returned."; } selectProduct(machine: VendingMachine, productId: string): string { // Cannot select during dispensing return "Please wait, dispensing in progress."; } refund(machine: VendingMachine): number { // Cannot refund during dispensing console.log("Refund requested during dispensing - ignored"); return 0; } onEnter(machine: VendingMachine): void { console.log(`[State] Entering DISPENSING for ${this.productId}`); // Perform the dispensing operation this.dispense(machine); } private dispense(machine: VendingMachine): void { // Deduct price and reduce stock const price = machine.getPrice(this.productId); machine.deductFromBalance(price); machine.decrementStock(this.productId); console.log(`[Dispense] Product ${this.productId} dispensed!`); // Transition based on remaining balance if (machine.getBalance() > 0) { machine.setState(new HasCoinsState()); } else { machine.setState(new IdleState()); } }}123456789101112131415161718192021222324252627282930313233
/** * State: Machine has no products available. * Allows refunds, rejects new interactions. */class OutOfStockState implements VendingMachineState { getName(): string { return "OUT_OF_STOCK"; } insertCoin(machine: VendingMachine, amount: number): string { // Accept but warn, or reject entirely - business decision return "Machine is out of stock. Please visit another machine."; } selectProduct(machine: VendingMachine, productId: string): string { return "Machine is out of stock. All products unavailable."; } refund(machine: VendingMachine): number { // Allow refund if any balance exists const amount = machine.getBalance(); if (amount > 0) { machine.setBalance(0); // Stay in OUT_OF_STOCK - we're still empty! } return amount; } onEnter(machine: VendingMachine): void { console.log("[State] Entering OUT_OF_STOCK - service required"); machine.notifyServiceTeam(); }}The Context Class
The context class ties everything together. It maintains the current state, provides the delegation mechanism, and exposes methods for state objects to manipulate shared data and trigger transitions.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
/** * Context class maintaining state and providing shared functionality. * Delegates state-dependent behavior to the current state object. */class VendingMachine { private state: VendingMachineState; private balance: number = 0; private inventory: Map<string, { price: number; stock: number }> = new Map(); constructor() { // Initialize with IDLE state this.state = new IdleState(); this.state.onEnter?.(this); // Initialize sample inventory this.inventory.set("COLA", { price: 150, stock: 5 }); this.inventory.set("CHIPS", { price: 100, stock: 3 }); this.inventory.set("CANDY", { price: 75, stock: 10 }); } // ========== State Management ========== setState(newState: VendingMachineState): void { console.log(`[Transition] ${this.state.getName()} → ${newState.getName()}`); // Call exit hook on current state this.state.onExit?.(this); // Update to new state this.state = newState; // Call enter hook on new state this.state.onEnter?.(this); } getStateName(): string { return this.state.getName(); } // ========== Delegated Operations ========== insertCoin(amount: number): string { return this.state.insertCoin(this, amount); } selectProduct(productId: string): string { return this.state.selectProduct(this, productId); } refund(): number { return this.state.refund(this); } // ========== Shared Data Access (for State Objects) ========== getBalance(): number { return this.balance; } setBalance(amount: number): void { this.balance = amount; } addToBalance(amount: number): void { this.balance += amount; } deductFromBalance(amount: number): void { this.balance -= amount; } getPrice(productId: string): number { return this.inventory.get(productId)?.price ?? 0; } getStock(productId: string): number { return this.inventory.get(productId)?.stock ?? 0; } decrementStock(productId: string): void { const item = this.inventory.get(productId); if (item && item.stock > 0) { item.stock -= 1; // Check if all products are now out of stock const totalStock = Array.from(this.inventory.values()) .reduce((sum, item) => sum + item.stock, 0); if (totalStock === 0) { this.setState(new OutOfStockState()); } } } notifyServiceTeam(): void { console.log("[Service] Notification sent: Machine requires restocking"); }}Key aspects of the context class:
-
Thin delegation methods — The public interface (
insertCoin,selectProduct,refund) simply forwards to the current state. No conditionals, no branching. -
State management — The
setState()method handles transitions, including calling lifecycle hooks. This is the single point of state change. -
Shared data exposure — The context provides methods for states to access and modify shared data (balance, inventory). These are the context's core responsibilities.
-
Business logic lives in states — The context contains no if-else logic about what to do in different states. That knowledge is encapsulated in state classes.
Note how clean the VendingMachine class is compared to the conditional version from Page 1. No switch statements, no state-checking conditionals, no scattered logic. The context focuses on what it owns (data) and delegates what varies (behavior) to states.
Using the Pattern
Let's see the State Pattern in action with a complete usage example. Observe how the machine's behavior changes automatically as states transition.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Create a vending machine (starts in IDLE state)const machine = new VendingMachine(); console.log("=== Customer Interaction ==="); // Try to select without moneyconsole.log(machine.selectProduct("COLA"));// Output: "Please insert coins first." // Insert coinsconsole.log(machine.insertCoin(100));// Output: "[Transition] IDLE → HAS_COINS"// Output: "Coin accepted. Balance: 100 cents." // Insert more coinsconsole.log(machine.insertCoin(50));// Output: "Additional coin accepted. Balance: 150 cents." // Select a productconsole.log(machine.selectProduct("COLA"));// Output: "[Transition] HAS_COINS → DISPENSING"// Output: "[Dispense] Product COLA dispensed!"// Output: "[Transition] DISPENSING → IDLE"// Output: "Dispensing COLA..." // Machine is back to IDLEconsole.log(machine.getStateName());// Output: "IDLE" console.log("=== Edge Case: Insufficient funds ==="); console.log(machine.insertCoin(50));// Output: "[Transition] IDLE → HAS_COINS" console.log(machine.selectProduct("COLA")); // Cola costs 150// Output: "Insufficient balance. Need 100 more cents for COLA." console.log(machine.selectProduct("CANDY")); // Candy costs 75// Output: "[Transition] HAS_COINS → DISPENSING"// Output: "[Dispense] Product CANDY dispensed!"// Output: "[Transition] DISPENSING → IDLE" console.log("=== Edge Case: Refund ==="); console.log(machine.insertCoin(100));console.log(`Refunded: ${machine.refund()} cents`);// Output: "Refunded: 100 cents"// Machine returns to IDLEWhat's happening behind the scenes:
-
When
insertCoin(100)is called, theIdleStatehandles it by adding to balance and transitioning toHasCoinsState. -
When
selectProduct("COLA")is called, theHasCoinsStatevalidates funds and stock, then transitions toDispensingState. -
The
DispensingState.onEnter()hook immediately dispenses the product and transitions back based on remaining balance. -
Each state operates independently, knowing only its own rules and the transitions it should trigger.
Notice that client code doesn't need to know about states at all. It calls insertCoin(), selectProduct(), and refund() without ever checking machine state. The machine 'just works' correctly in any state because each state handles operations appropriately.
Comparing Approaches: Before and After
Let's directly compare the conditional approach with the State Pattern approach to crystallize the benefits:
- •One large class with switch statements
- •State logic scattered across all methods
- •Adding state requires modifying every method
- •Adding operation requires adding to every switch
- •Understanding one state means reading entire class
- •Testing states requires complex setup
- •Compiler can't enforce state coverage
- •Multiple focused classes, each with clear purpose
- •State logic encapsulated in state classes
- •Adding state: create new class, implement interface
- •Adding operation: add to interface, implement in each state
- •Understanding one state: read one class
- •Testing states: instantiate state, test directly
- •Interface compliance enforced by compiler
| Metric | Conditional (4 states, 3 ops) | State Pattern (4 states, 3 ops) |
|---|---|---|
| Classes/files | 1 | 5 (1 context, 4 states) |
| Conditional branches | 12 (3 ops × 4 states) | 0 in context |
| Lines per method | ~40 (switch with 4 cases) | ~10 (single behavior) |
| Adding 5th state | Modify 3 methods | Add 1 class |
| Adding 4th operation | Add to 4 switch cases | Add to interface + 4 classes |
| Testing one state | Setup + navigate branches | Instantiate + test directly |
When does the pattern pay off?
The State Pattern introduces more classes but eliminates conditional complexity. The trade-off is worthwhile when:
- States will be added — Each new state is a new class, not modifications to existing code.
- State behavior is complex — Each state class can be as sophisticated as needed without cluttering others.
- Testing is priority — States can be tested in isolation.
- Team size is growing — Different developers can work on different states without conflicts.
Critics note the State Pattern creates more classes. This is true but misleading. Would you rather have one 500-line class with 40 conditional branches, or five 50-line classes with zero? Smaller, focused classes are easier to understand, test, and maintain—even if there are more of them.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.