Loading learning content...
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 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.
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.
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:
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.
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.
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 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.
Let's see the State Pattern in action with a complete usage example. Observe how the machine's behavior changes automatically as states transition.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Create a vending machine (starts in IDLE state)const machine = new VendingMachine(); console.log("=== Customer Interaction ===\n"); // 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("\n=== Edge Case: Insufficient funds ===\n"); 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("\n=== Edge Case: Refund ===\n"); 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, the IdleState handles it by adding to balance and transitioning to HasCoinsState.
When selectProduct("COLA") is called, the HasCoinsState validates funds and stock, then transitions to DispensingState.
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.
Let's directly compare the conditional approach with the State Pattern approach to crystallize the benefits:
| 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:
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.
The State Pattern delivers concrete benefits that compound over the lifetime of a system. Let's examine each in detail.
machine.setState(new HasCoinsState()). Transitions can be logged, validated, or intercepted.These benefits compound over time. Year one, the pattern might seem like overhead. Year three, with 10 states and 15 operations, you'll have added 6 states and 10 operations—each as a simple, isolated change—while the conditional approach would have become a maintenance nightmare.
We've seen how the State Pattern transforms state-dependent behavior from a maintenance challenge into an elegant, extensible design. Let's consolidate the key insights:
What's next:
Understanding the structure is half the battle. In the next page, we'll dive deep into state transitions—the mechanics of how states change, strategies for managing transition logic, and handling complex transition scenarios including guarded transitions, transition validation, and event-driven state changes.
You now understand the State Pattern's structure and mechanics. State objects encapsulate behavior, the context delegates operations, and transitions are explicit method calls. Next, we'll explore the nuances of state transitions—the heart of any state machine.