Episode 9 — System Design / 9.5 — Behavioral Design Patterns
9.5.e State Pattern
Overview
The State Pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class. Instead of using massive if/else or switch statements to check the current state, the behavior is delegated to state objects.
Think of a traffic light: when it's red, the behavior is "stop." When it's green, the behavior is "go." The light doesn't have if (color === 'red') checks everywhere -- each state encapsulates its own behavior.
+--------------------------------------------------------------+
| STATE PATTERN |
| |
| Context |
| +--------------------+ +-------------------+ |
| | | | State Interface | |
| | - currentState ---+----->| + handle() | |
| | | | + canTransitionTo()| |
| | + setState() | +-------------------+ |
| | + request() | ^ |
| | (delegates to | | |
| | currentState) | +---------+---------+ |
| +--------------------+ | | | |
| +--+---+ +---+---+ +---+---+ |
| |State | |State | |State | |
| | A | | B | | C | |
| +------+ +-------+ +-------+ |
| |
| "Object behavior changes based on internal state." |
+--------------------------------------------------------------+
The Problem: State Spaghetti
// BAD: State management via conditionals
class Order {
constructor() {
this.status = 'pending';
}
process() {
if (this.status === 'pending') {
this.status = 'processing';
console.log('Order is being processed');
} else if (this.status === 'processing') {
console.log('Order is already being processed');
} else if (this.status === 'shipped') {
console.log('Cannot process - already shipped');
} else if (this.status === 'delivered') {
console.log('Cannot process - already delivered');
} else if (this.status === 'cancelled') {
console.log('Cannot process - order was cancelled');
}
// Every method needs these checks for EVERY state
// Adding a new state means modifying EVERY method
}
ship() {
if (this.status === 'processing') {
this.status = 'shipped';
// ...
} else if (this.status === 'pending') {
// ...
}
// Same conditional nightmare
}
cancel() {
// Even more conditionals...
}
}
State Diagram: Order Lifecycle
+-------------------------------------------------------------------+
| ORDER STATE MACHINE |
| |
| +----------+ |
| | PENDING | |
| +----+-----+ |
| | |
| pay() | |
| v |
| cancel() +-----------+ |
| +-----------------| PAID | |
| | +-----+-----+ |
| | | |
| | process| |
| v v |
| +----------+ +-----------+ |
| |CANCELLED | |PROCESSING| |
| +----------+ +-----+-----+ |
| | |
| ship()| |
| v |
| +-----------+ |
| | SHIPPED | |
| +-----+-----+ |
| | |
| deliver()| |
| v |
| +-----------+ |
| | DELIVERED | |
| +-----------+ |
| |
| Valid transitions only. Invalid ones throw errors. |
+-------------------------------------------------------------------+
Implementation 1: Order Status State Machine
// ============================================
// STATE INTERFACE
// ============================================
class OrderState {
constructor(order) {
this.order = order;
}
getName() {
throw new Error('Must implement getName()');
}
pay() {
throw new Error(`Cannot pay in ${this.getName()} state`);
}
process() {
throw new Error(`Cannot process in ${this.getName()} state`);
}
ship() {
throw new Error(`Cannot ship in ${this.getName()} state`);
}
deliver() {
throw new Error(`Cannot deliver in ${this.getName()} state`);
}
cancel() {
throw new Error(`Cannot cancel in ${this.getName()} state`);
}
getAvailableActions() {
return [];
}
toString() {
return this.getName();
}
}
// ============================================
// CONCRETE STATES
// ============================================
class PendingState extends OrderState {
getName() { return 'PENDING'; }
pay(paymentDetails) {
console.log(`[Order ${this.order.id}] Payment received: $${paymentDetails.amount}`);
this.order.paymentDetails = paymentDetails;
this.order.setState(new PaidState(this.order));
}
cancel(reason) {
console.log(`[Order ${this.order.id}] Cancelled while pending. Reason: ${reason}`);
this.order.cancellationReason = reason;
this.order.setState(new CancelledState(this.order));
}
getAvailableActions() {
return ['pay', 'cancel'];
}
}
class PaidState extends OrderState {
getName() { return 'PAID'; }
process() {
console.log(`[Order ${this.order.id}] Processing order...`);
this.order.processedAt = Date.now();
this.order.setState(new ProcessingState(this.order));
}
cancel(reason) {
console.log(`[Order ${this.order.id}] Cancelled after payment. Refund initiated.`);
this.order.cancellationReason = reason;
this.order.refundInitiated = true;
this.order.setState(new CancelledState(this.order));
}
getAvailableActions() {
return ['process', 'cancel'];
}
}
class ProcessingState extends OrderState {
getName() { return 'PROCESSING'; }
ship(trackingNumber) {
console.log(`[Order ${this.order.id}] Shipped! Tracking: ${trackingNumber}`);
this.order.trackingNumber = trackingNumber;
this.order.shippedAt = Date.now();
this.order.setState(new ShippedState(this.order));
}
getAvailableActions() {
return ['ship'];
}
}
class ShippedState extends OrderState {
getName() { return 'SHIPPED'; }
deliver() {
console.log(`[Order ${this.order.id}] Delivered successfully!`);
this.order.deliveredAt = Date.now();
this.order.setState(new DeliveredState(this.order));
}
getAvailableActions() {
return ['deliver'];
}
}
class DeliveredState extends OrderState {
getName() { return 'DELIVERED'; }
// Terminal state -- no transitions out
getAvailableActions() {
return [];
}
}
class CancelledState extends OrderState {
getName() { return 'CANCELLED'; }
// Terminal state -- no transitions out
getAvailableActions() {
return [];
}
}
// ============================================
// CONTEXT: Order
// ============================================
class Order {
constructor(id, items) {
this.id = id;
this.items = items;
this.createdAt = Date.now();
this.stateHistory = [];
// Start in pending state
this.state = new PendingState(this);
this.stateHistory.push({
state: 'PENDING',
timestamp: this.createdAt
});
}
setState(newState) {
const oldStateName = this.state.getName();
const newStateName = newState.getName();
console.log(` [Transition] ${oldStateName} -> ${newStateName}`);
this.state = newState;
this.stateHistory.push({
state: newStateName,
timestamp: Date.now(),
from: oldStateName
});
}
// Delegate all actions to the current state
pay(details) { this.state.pay(details); }
process() { this.state.process(); }
ship(tracking) { this.state.ship(tracking); }
deliver() { this.state.deliver(); }
cancel(reason) { this.state.cancel(reason); }
getStatus() {
return this.state.getName();
}
getAvailableActions() {
return this.state.getAvailableActions();
}
getHistory() {
return this.stateHistory.map(h =>
`${h.state}${h.from ? ` (from ${h.from})` : ''}`
);
}
getSummary() {
return {
id: this.id,
status: this.getStatus(),
items: this.items.length,
availableActions: this.getAvailableActions(),
history: this.getHistory()
};
}
}
// ============================================
// USAGE
// ============================================
const order = new Order('ORD-001', [
{ name: 'Widget', quantity: 2, price: 29.99 },
{ name: 'Gadget', quantity: 1, price: 49.99 }
]);
console.log('Available:', order.getAvailableActions()); // ['pay', 'cancel']
order.pay({ amount: 109.97, method: 'credit_card' });
console.log('Available:', order.getAvailableActions()); // ['process', 'cancel']
order.process();
console.log('Available:', order.getAvailableActions()); // ['ship']
// Try invalid transition
try {
order.cancel('Changed my mind');
} catch (e) {
console.log(`Error: ${e.message}`);
// "Cannot cancel in PROCESSING state"
}
order.ship('TRACK-12345');
order.deliver();
console.log('\nOrder Summary:', JSON.stringify(order.getSummary(), null, 2));
console.log('History:', order.getHistory());
Implementation 2: Vending Machine
// ============================================
// VENDING MACHINE STATE DIAGRAM
// ============================================
//
// +--------+ insertCoin() +-----------+
// | IDLE |--------------->| HAS_MONEY |<--+
// +--------+ +-----+-----+ |
// ^ | |
// | refund() | select() | addMore()
// | v |
// | +------+------+ |
// +--------------------| DISPENSING |---+
// (no more items) +-------------+
//
// ============================================
// VENDING MACHINE STATES
// ============================================
class VendingState {
constructor(machine) {
this.machine = machine;
}
insertCoin(amount) {
console.log(`[${this.getName()}] Cannot insert coin in this state`);
}
selectProduct(code) {
console.log(`[${this.getName()}] Cannot select product in this state`);
}
dispense() {
console.log(`[${this.getName()}] Cannot dispense in this state`);
}
refund() {
console.log(`[${this.getName()}] Nothing to refund`);
}
getName() { return 'Unknown'; }
}
class IdleState extends VendingState {
getName() { return 'IDLE'; }
insertCoin(amount) {
this.machine.balance += amount;
console.log(`[IDLE] Inserted $${amount.toFixed(2)}. Balance: $${this.machine.balance.toFixed(2)}`);
this.machine.setState(new HasMoneyState(this.machine));
}
selectProduct(code) {
console.log('[IDLE] Please insert coins first');
}
}
class HasMoneyState extends VendingState {
getName() { return 'HAS_MONEY'; }
insertCoin(amount) {
this.machine.balance += amount;
console.log(
`[HAS_MONEY] Added $${amount.toFixed(2)}. ` +
`Balance: $${this.machine.balance.toFixed(2)}`
);
}
selectProduct(code) {
const product = this.machine.inventory.get(code);
if (!product) {
console.log(`[HAS_MONEY] Product ${code} not found`);
return;
}
if (product.quantity <= 0) {
console.log(`[HAS_MONEY] ${product.name} is sold out`);
return;
}
if (this.machine.balance < product.price) {
console.log(
`[HAS_MONEY] Insufficient funds. ` +
`Need $${product.price.toFixed(2)}, have $${this.machine.balance.toFixed(2)}`
);
return;
}
this.machine.selectedProduct = product;
this.machine.selectedCode = code;
console.log(`[HAS_MONEY] Selected: ${product.name} ($${product.price.toFixed(2)})`);
this.machine.setState(new DispensingState(this.machine));
}
refund() {
const refundAmount = this.machine.balance;
this.machine.balance = 0;
console.log(`[HAS_MONEY] Refunded $${refundAmount.toFixed(2)}`);
this.machine.setState(new IdleState(this.machine));
}
}
class DispensingState extends VendingState {
getName() { return 'DISPENSING'; }
constructor(machine) {
super(machine);
// Auto-dispense when entering this state
setTimeout(() => this.dispense(), 0);
}
dispense() {
const product = this.machine.selectedProduct;
const code = this.machine.selectedCode;
// Deduct price
this.machine.balance -= product.price;
// Update inventory
product.quantity--;
console.log(`[DISPENSING] Dispensed: ${product.name}`);
// Return change
if (this.machine.balance > 0) {
console.log(`[DISPENSING] Change: $${this.machine.balance.toFixed(2)}`);
this.machine.balance = 0;
}
// Clear selection
this.machine.selectedProduct = null;
this.machine.selectedCode = null;
// Record sale
this.machine.salesLog.push({
product: product.name,
code,
price: product.price,
timestamp: Date.now()
});
this.machine.setState(new IdleState(this.machine));
}
insertCoin(amount) {
console.log('[DISPENSING] Please wait, dispensing in progress');
}
selectProduct(code) {
console.log('[DISPENSING] Please wait, dispensing in progress');
}
}
// ============================================
// CONTEXT: Vending Machine
// ============================================
class VendingMachine {
constructor() {
this.balance = 0;
this.inventory = new Map();
this.selectedProduct = null;
this.selectedCode = null;
this.salesLog = [];
this.state = new IdleState(this);
}
setState(state) {
const oldState = this.state?.getName() || 'INIT';
this.state = state;
console.log(` [State] ${oldState} -> ${state.getName()}`);
}
addProduct(code, name, price, quantity) {
this.inventory.set(code, { name, price, quantity });
}
// Delegate to current state
insertCoin(amount) { this.state.insertCoin(amount); }
selectProduct(code) { this.state.selectProduct(code); }
refund() { this.state.refund(); }
displayProducts() {
console.log('\n=== VENDING MACHINE ===');
for (const [code, product] of this.inventory) {
const status = product.quantity > 0 ? `${product.quantity} left` : 'SOLD OUT';
console.log(` [${code}] ${product.name} - $${product.price.toFixed(2)} (${status})`);
}
console.log(` Balance: $${this.balance.toFixed(2)}`);
console.log(` State: ${this.state.getName()}`);
console.log('========================\n');
}
}
// ============================================
// USAGE
// ============================================
const machine = new VendingMachine();
machine.addProduct('A1', 'Cola', 1.50, 5);
machine.addProduct('A2', 'Chips', 2.00, 3);
machine.addProduct('B1', 'Water', 1.00, 10);
machine.addProduct('B2', 'Candy', 0.75, 0); // Sold out
machine.displayProducts();
machine.selectProduct('A1'); // "Please insert coins first"
machine.insertCoin(1.00); // Balance: $1.00
machine.selectProduct('A2'); // Insufficient funds
machine.insertCoin(1.00); // Balance: $2.00
machine.selectProduct('A1'); // Dispensed! Change: $0.50
Implementation 3: Traffic Light
// ============================================
// TRAFFIC LIGHT STATE MACHINE
// ============================================
//
// +-------+ timer +--------+ timer +------+
// | GREEN |-------------->| YELLOW |-------------->| RED |
// +---+---+ +--------+ +--+---+
// ^ |
// | timer |
// +-----------------------------------------------+
//
class TrafficLightState {
constructor(light) {
this.light = light;
}
getName() { throw new Error('Must implement'); }
getColor() { throw new Error('Must implement'); }
getDuration() { throw new Error('Must implement'); } // in ms
getNextState() { throw new Error('Must implement'); }
enter() {
console.log(
`[Traffic Light] ${this.getColor()} ` +
`(${this.getDuration() / 1000}s) - ${this.getMessage()}`
);
}
getMessage() { return ''; }
tick() {
// Called when timer expires -- transition to next state
const NextStateClass = this.getNextState();
this.light.setState(new NextStateClass(this.light));
}
}
class GreenState extends TrafficLightState {
getName() { return 'GREEN'; }
getColor() { return 'GREEN'; }
getDuration() { return 5000; }
getMessage() { return 'GO - Traffic may proceed'; }
getNextState() { return YellowState; }
}
class YellowState extends TrafficLightState {
getName() { return 'YELLOW'; }
getColor() { return 'YELLOW'; }
getDuration() { return 2000; }
getMessage() { return 'CAUTION - Prepare to stop'; }
getNextState() { return RedState; }
}
class RedState extends TrafficLightState {
getName() { return 'RED'; }
getColor() { return 'RED'; }
getDuration() { return 5000; }
getMessage() { return 'STOP - Do not proceed'; }
getNextState() { return GreenState; }
}
class TrafficLight {
constructor(id) {
this.id = id;
this.state = null;
this.timer = null;
this.cycleCount = 0;
this.log = [];
}
setState(state) {
if (this.timer) clearTimeout(this.timer);
const oldState = this.state?.getName() || 'INIT';
this.state = state;
this.log.push({
from: oldState,
to: state.getName(),
timestamp: Date.now()
});
state.enter();
// Auto-transition after duration
this.timer = setTimeout(() => {
if (state.getName() === 'RED') this.cycleCount++;
state.tick();
}, state.getDuration());
}
start() {
console.log(`[Traffic Light ${this.id}] Starting...`);
this.setState(new RedState(this));
}
stop() {
if (this.timer) clearTimeout(this.timer);
console.log(`[Traffic Light ${this.id}] Stopped after ${this.cycleCount} cycles`);
}
emergencyRed() {
console.log(`[Traffic Light ${this.id}] EMERGENCY - Forcing RED`);
this.setState(new RedState(this));
}
getStatus() {
return {
id: this.id,
currentState: this.state?.getName(),
cycleCount: this.cycleCount
};
}
}
// ============================================
// ASCII State Diagram
// ============================================
function printStateDiagram() {
console.log(`
TRAFFIC LIGHT STATE DIAGRAM
============================
+--------+ 5s +--------+ 2s +-----+
| GREEN |-------->| YELLOW |-------->| RED |
+--------+ +--------+ +--+--+
^ |
| 5s |
+-----------------------------------+
States:
GREEN : Duration 5s, allows traffic flow
YELLOW : Duration 2s, warning to slow down
RED : Duration 5s, traffic must stop
Transitions are automatic based on timers.
`);
}
printStateDiagram();
// USAGE (synchronous demo -- not starting timers)
const light = new TrafficLight('TL-001');
// light.start();
// setTimeout(() => light.stop(), 30000);
Implementation 4: Document Workflow
// ============================================
// DOCUMENT APPROVAL WORKFLOW
// ============================================
//
// State Diagram:
//
// DRAFT --submit()--> REVIEW --approve()--> APPROVED --publish()--> PUBLISHED
// ^ | |
// | reject() | archive() |
// +-------------------+ v
// ARCHIVED
//
class DocumentState {
constructor(doc) {
this.doc = doc;
}
edit(content) {
throw new Error(`Cannot edit in ${this.getName()} state`);
}
submit() {
throw new Error(`Cannot submit in ${this.getName()} state`);
}
approve(approver) {
throw new Error(`Cannot approve in ${this.getName()} state`);
}
reject(reason) {
throw new Error(`Cannot reject in ${this.getName()} state`);
}
publish() {
throw new Error(`Cannot publish in ${this.getName()} state`);
}
archive() {
throw new Error(`Cannot archive in ${this.getName()} state`);
}
getName() { return 'Unknown'; }
}
class DraftState extends DocumentState {
getName() { return 'DRAFT'; }
edit(content) {
this.doc.content = content;
this.doc.lastEditedAt = Date.now();
console.log(`[DRAFT] Document edited. Length: ${content.length}`);
}
submit() {
if (!this.doc.content || this.doc.content.length < 10) {
throw new Error('Document must have at least 10 characters to submit');
}
console.log(`[DRAFT] Document submitted for review`);
this.doc.submittedAt = Date.now();
this.doc.setState(new ReviewState(this.doc));
}
}
class ReviewState extends DocumentState {
getName() { return 'REVIEW'; }
approve(approver) {
console.log(`[REVIEW] Approved by ${approver}`);
this.doc.approvedBy = approver;
this.doc.approvedAt = Date.now();
this.doc.setState(new ApprovedState(this.doc));
}
reject(reason) {
console.log(`[REVIEW] Rejected. Reason: ${reason}`);
this.doc.rejectionReason = reason;
this.doc.setState(new DraftState(this.doc));
}
}
class ApprovedState extends DocumentState {
getName() { return 'APPROVED'; }
publish() {
console.log(`[APPROVED] Document published!`);
this.doc.publishedAt = Date.now();
this.doc.setState(new PublishedState(this.doc));
}
}
class PublishedState extends DocumentState {
getName() { return 'PUBLISHED'; }
archive() {
console.log(`[PUBLISHED] Document archived`);
this.doc.archivedAt = Date.now();
this.doc.setState(new ArchivedState(this.doc));
}
}
class ArchivedState extends DocumentState {
getName() { return 'ARCHIVED'; }
// Terminal state
}
class Document {
constructor(title, author) {
this.title = title;
this.author = author;
this.content = '';
this.createdAt = Date.now();
this.state = new DraftState(this);
this.history = [{ state: 'DRAFT', timestamp: this.createdAt }];
}
setState(state) {
this.state = state;
this.history.push({ state: state.getName(), timestamp: Date.now() });
}
// Delegates
edit(content) { this.state.edit(content); }
submit() { this.state.submit(); }
approve(approver) { this.state.approve(approver); }
reject(reason) { this.state.reject(reason); }
publish() { this.state.publish(); }
archive() { this.state.archive(); }
getStatus() { return this.state.getName(); }
}
// ============================================
// USAGE
// ============================================
const doc = new Document('Design Patterns Guide', 'Alice');
doc.edit('This is a comprehensive guide to design patterns...');
doc.submit();
// Try publishing before approval
try {
doc.publish();
} catch (e) {
console.log(`Error: ${e.message}`);
}
doc.reject('Needs more examples');
console.log(`Status: ${doc.getStatus()}`); // DRAFT
doc.edit('This guide covers 23 design patterns with code examples...');
doc.submit();
doc.approve('Bob');
doc.publish();
doc.archive();
console.log('Document history:', doc.history.map(h => h.state));
// ['DRAFT', 'REVIEW', 'DRAFT', 'REVIEW', 'APPROVED', 'PUBLISHED', 'ARCHIVED']
State vs Strategy
+------------------------------------------------------------------+
| |
| STATE PATTERN STRATEGY PATTERN |
| |
| - State transitions happen - Client chooses the |
| AUTOMATICALLY inside the strategy EXPLICITLY |
| state objects |
| |
| - States know about each other - Strategies don't know |
| (they trigger transitions) about each other |
| |
| - Context behavior changes - Context behavior changes |
| based on INTERNAL state based on EXTERNAL choice |
| |
| - Replaces state conditionals - Replaces algorithm |
| (if state === 'X') conditionals |
| |
| Example: Example: |
| Order goes from PENDING User picks payment method |
| to PAID to SHIPPED (credit card vs PayPal) |
| automatically based on based on their preference |
| business rules |
| |
+------------------------------------------------------------------+
Key Takeaways
- State pattern eliminates state-dependent conditionals by encapsulating behavior in state objects
- Each state knows its valid transitions -- invalid actions throw errors instead of silently failing
- State transitions can carry data -- the from-state can pass context to the to-state
- State pattern is ideal for workflows -- order processing, document approval, game states, UI modes
- State objects can be shared (Flyweight) if they don't hold instance-specific data
- Keep a state history for debugging, auditing, and potential state replay
Explain-It Challenge
You're building a video player component. The player has states: Idle, Loading, Playing, Paused, Buffering, Error, and Ended. Design the state machine using the State pattern. Consider: (a) what happens if the user clicks "play" while buffering, (b) how to handle network errors that can occur in any state, (c) how to implement a "resume from where you left off" feature using state history.