DevDocsDev Docs
Design PatternsBehavioral Patterns

State

Allow an object to alter its behavior when its internal state changes

State Pattern

Intent

State is a behavioral design pattern that lets an object alter its behavior when its internal state changes. It appears as if the object changed its class. The pattern extracts state-related behaviors into separate state classes.


Problem It Solves

Objects with state-dependent behavior often use conditionals:

Adding new states requires modifying existing code everywhere.


Solution

Encapsulate each state in its own object:

Each state handles its own transitions and behavior.


Implementation

/**
 * Vending machine context interface
 */
interface VendingMachine {
  insertCoin: (amount: number) => void;
  selectProduct: (productId: string) => void;
  dispense: () => void;
  refund: () => void;
  getBalance: () => number;
  getState: () => string;
}

/**
 * Vending machine state interface
 */
interface VendingState {
  name: string;
  insertCoin: (context: VendingMachineContext, amount: number) => void;
  selectProduct: (context: VendingMachineContext, productId: string) => void;
  dispense: (context: VendingMachineContext) => void;
  refund: (context: VendingMachineContext) => void;
}

/**
 * Internal context for state management
 */
interface VendingMachineContext {
  balance: number;
  selectedProduct: string | null;
  products: Map<string, { name: string; price: number; stock: number }>;
  setState: (state: VendingState) => void;
  getState: () => VendingState;
}

/**
 * Idle state - waiting for coins
 */
const idleState: VendingState = {
  name: "Idle",
  
  insertCoin(context, amount) {
    context.balance += amount;
    console.log(`Inserted $${amount.toFixed(2)}. Balance: $${context.balance.toFixed(2)}`);
    context.setState(hasMoneyState);
  },
  
  selectProduct(context, productId) {
    console.log("Please insert coins first");
  },
  
  dispense(context) {
    console.log("Please select a product first");
  },
  
  refund(context) {
    console.log("No money to refund");
  },
};

/**
 * Has money state - coins inserted
 */
const hasMoneyState: VendingState = {
  name: "HasMoney",
  
  insertCoin(context, amount) {
    context.balance += amount;
    console.log(`Inserted $${amount.toFixed(2)}. Balance: $${context.balance.toFixed(2)}`);
  },
  
  selectProduct(context, productId) {
    const product = context.products.get(productId);
    
    if (!product) {
      console.log("Product not found");
      return;
    }
    
    if (product.stock === 0) {
      console.log(`${product.name} is out of stock`);
      return;
    }
    
    if (context.balance < product.price) {
      console.log(`Insufficient funds. Need $${(product.price - context.balance).toFixed(2)} more`);
      return;
    }
    
    context.selectedProduct = productId;
    console.log(`Selected: ${product.name}`);
    context.setState(dispensingState);
  },
  
  dispense(context) {
    console.log("Please select a product first");
  },
  
  refund(context) {
    console.log(`Refunding $${context.balance.toFixed(2)}`);
    context.balance = 0;
    context.setState(idleState);
  },
};

/**
 * Dispensing state - product selected
 */
const dispensingState: VendingState = {
  name: "Dispensing",
  
  insertCoin(context, amount) {
    console.log("Please wait, dispensing product...");
  },
  
  selectProduct(context, productId) {
    console.log("Please wait, dispensing product...");
  },
  
  dispense(context) {
    const product = context.products.get(context.selectedProduct!);
    if (!product) return;
    
    product.stock -= 1;
    context.balance -= product.price;
    
    console.log(`Dispensing: ${product.name}`);
    
    if (context.balance > 0) {
      console.log(`Change: $${context.balance.toFixed(2)}`);
      context.balance = 0;
    }
    
    context.selectedProduct = null;
    context.setState(idleState);
  },
  
  refund(context) {
    console.log("Cannot refund while dispensing");
  },
};

/**
 * Create a vending machine
 */
const createVendingMachine = (): VendingMachine => {
  const context: VendingMachineContext = {
    balance: 0,
    selectedProduct: null,
    products: new Map([
      ["A1", { name: "Cola", price: 1.50, stock: 5 }],
      ["A2", { name: "Chips", price: 1.25, stock: 3 }],
      ["B1", { name: "Candy", price: 0.75, stock: 10 }],
    ]),
    setState(state) {
      currentState = state;
      console.log(`[State → ${state.name}]`);
    },
    getState() {
      return currentState;
    },
  };

  let currentState: VendingState = idleState;

  return {
    insertCoin: (amount) => currentState.insertCoin(context, amount),
    selectProduct: (id) => currentState.selectProduct(context, id),
    dispense: () => currentState.dispense(context),
    refund: () => currentState.refund(context),
    getBalance: () => context.balance,
    getState: () => currentState.name,
  };
};

// Usage
const machine = createVendingMachine();

console.log("\n--- Vending Machine Demo ---\n");

// Try without money
machine.selectProduct("A1");

// Insert coins
machine.insertCoin(1.00);
machine.insertCoin(0.25);
machine.insertCoin(0.25);

// Select product
machine.selectProduct("A1");

// Dispense
machine.dispense();

const finalState = machine.getState();
//    ^?
console.log(`\nFinal state: ${finalState}`);
console.log(`Balance: $${machine.getBalance().toFixed(2)}`);
/**
 * Document data
 */
interface DocumentData {
  id: string;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
  updatedAt: Date;
  publishedAt?: Date;
}

/**
 * Document context for state management
 */
interface DocumentContext {
  data: DocumentData;
  setState: (state: DocumentState) => void;
  getState: () => DocumentState;
}

/**
 * Document state interface
 */
interface DocumentState {
  name: string;
  edit: (context: DocumentContext, content: string) => void;
  submit: (context: DocumentContext) => void;
  approve: (context: DocumentContext) => void;
  reject: (context: DocumentContext, reason: string) => void;
  publish: (context: DocumentContext) => void;
  archive: (context: DocumentContext) => void;
}

/**
 * Draft state - document being written
 */
const draftState: DocumentState = {
  name: "Draft",
  
  edit(context, content) {
    context.data.content = content;
    context.data.updatedAt = new Date();
    console.log(`Document edited: "${content.substring(0, 30)}..."`);
  },
  
  submit(context) {
    if (!context.data.content.trim()) {
      console.log("Cannot submit empty document");
      return;
    }
    console.log("Document submitted for review");
    context.setState(pendingReviewState);
  },
  
  approve(context) {
    console.log("Cannot approve: Document not submitted");
  },
  
  reject(context, reason) {
    console.log("Cannot reject: Document not submitted");
  },
  
  publish(context) {
    console.log("Cannot publish: Document must be approved first");
  },
  
  archive(context) {
    console.log("Document archived (discarded)");
    context.setState(archivedState);
  },
};

/**
 * Pending review state
 */
const pendingReviewState: DocumentState = {
  name: "Pending Review",
  
  edit(context, content) {
    console.log("Cannot edit: Document is pending review");
  },
  
  submit(context) {
    console.log("Document already submitted");
  },
  
  approve(context) {
    console.log("Document approved!");
    context.setState(approvedState);
  },
  
  reject(context, reason) {
    console.log(`Document rejected: ${reason}`);
    context.setState(draftState);
  },
  
  publish(context) {
    console.log("Cannot publish: Document must be approved first");
  },
  
  archive(context) {
    console.log("Cannot archive: Document is pending review");
  },
};

/**
 * Approved state
 */
const approvedState: DocumentState = {
  name: "Approved",
  
  edit(context, content) {
    console.log("Warning: Editing approved document returns it to draft");
    context.data.content = content;
    context.data.updatedAt = new Date();
    context.setState(draftState);
  },
  
  submit(context) {
    console.log("Document already approved");
  },
  
  approve(context) {
    console.log("Document already approved");
  },
  
  reject(context, reason) {
    console.log(`Document approval revoked: ${reason}`);
    context.setState(draftState);
  },
  
  publish(context) {
    context.data.publishedAt = new Date();
    console.log("Document published!");
    context.setState(publishedState);
  },
  
  archive(context) {
    console.log("Document archived");
    context.setState(archivedState);
  },
};

/**
 * Published state
 */
const publishedState: DocumentState = {
  name: "Published",
  
  edit(context, content) {
    console.log("Cannot edit published document. Create a new version.");
  },
  
  submit(context) {
    console.log("Document already published");
  },
  
  approve(context) {
    console.log("Document already published");
  },
  
  reject(context, reason) {
    console.log("Cannot reject: Document is published");
  },
  
  publish(context) {
    console.log("Document already published");
  },
  
  archive(context) {
    console.log("Document unpublished and archived");
    context.setState(archivedState);
  },
};

/**
 * Archived state
 */
const archivedState: DocumentState = {
  name: "Archived",
  
  edit(context, content) {
    console.log("Cannot edit archived document");
  },
  
  submit(context) {
    console.log("Cannot submit: Document is archived");
  },
  
  approve(context) {
    console.log("Cannot approve: Document is archived");
  },
  
  reject(context, reason) {
    console.log("Cannot reject: Document is archived");
  },
  
  publish(context) {
    console.log("Cannot publish: Document is archived");
  },
  
  archive(context) {
    console.log("Document already archived");
  },
};

/**
 * Document interface
 */
interface Document {
  edit: (content: string) => void;
  submit: () => void;
  approve: () => void;
  reject: (reason: string) => void;
  publish: () => void;
  archive: () => void;
  getState: () => string;
  getData: () => DocumentData;
}

/**
 * Create a document with state management
 */
const createDocument = (title: string, author: string): Document => {
  let currentState: DocumentState = draftState;
  
  const context: DocumentContext = {
    data: {
      id: `doc_${Date.now()}`,
      title,
      content: "",
      author,
      createdAt: new Date(),
      updatedAt: new Date(),
    },
    setState(state) {
      currentState = state;
      console.log(`[Document state → ${state.name}]`);
    },
    getState: () => currentState,
  };

  return {
    edit: (content) => currentState.edit(context, content),
    submit: () => currentState.submit(context),
    approve: () => currentState.approve(context),
    reject: (reason) => currentState.reject(context, reason),
    publish: () => currentState.publish(context),
    archive: () => currentState.archive(context),
    getState: () => currentState.name,
    getData: () => ({ ...context.data }),
  };
};

// Usage
const doc = createDocument("Design Patterns Guide", "John Doe");

console.log("\n--- Document Workflow Demo ---\n");

// Write content
doc.edit("This is the initial content of our design patterns guide...");

// Try to publish without approval
doc.publish();

// Submit for review
doc.submit();

// Try to edit while pending
doc.edit("Trying to make changes...");

// Reject
doc.reject("Needs more examples");

// Edit and resubmit
doc.edit("Updated content with more examples and explanations...");
doc.submit();

// Approve and publish
doc.approve();
doc.publish();

console.log(`\nFinal state: ${doc.getState()}`);
console.log(`Published at: ${doc.getData().publishedAt}`);
/**
 * Connection events
 */
type ConnectionEvent = 
  | { type: "connect" }
  | { type: "disconnect" }
  | { type: "error"; error: Error }
  | { type: "data"; payload: unknown };

/**
 * Connection context
 */
interface ConnectionContext {
  url: string;
  retryCount: number;
  maxRetries: number;
  data: unknown[];
  setState: (state: ConnectionState) => void;
  emit: (event: string, data?: unknown) => void;
}

/**
 * Connection state interface
 */
interface ConnectionState {
  name: string;
  connect: (context: ConnectionContext) => Promise<void>;
  disconnect: (context: ConnectionContext) => void;
  send: (context: ConnectionContext, data: unknown) => void;
  handleError: (context: ConnectionContext, error: Error) => void;
}

/**
 * Disconnected state
 */
const disconnectedState: ConnectionState = {
  name: "Disconnected",
  
  async connect(context) {
    console.log(`Connecting to ${context.url}...`);
    context.setState(connectingState);
    
    // Simulate connection attempt
    await new Promise(r => setTimeout(r, 500));
    
    // Simulate success (in real code, this would be actual connection logic)
    const success = Math.random() > 0.3;
    
    if (success) {
      context.retryCount = 0;
      context.setState(connectedState);
      context.emit("connected");
    } else {
      connectingState.handleError(context, new Error("Connection failed"));
    }
  },
  
  disconnect(context) {
    console.log("Already disconnected");
  },
  
  send(context, data) {
    console.log("Cannot send: Not connected");
  },
  
  handleError(context, error) {
    console.log(`Error while disconnected: ${error.message}`);
  },
};

/**
 * Connecting state
 */
const connectingState: ConnectionState = {
  name: "Connecting",
  
  async connect(context) {
    console.log("Already connecting...");
  },
  
  disconnect(context) {
    console.log("Connection cancelled");
    context.setState(disconnectedState);
  },
  
  send(context, data) {
    console.log("Cannot send: Still connecting");
  },
  
  handleError(context, error) {
    console.log(`Connection error: ${error.message}`);
    
    if (context.retryCount < context.maxRetries) {
      context.retryCount++;
      console.log(`Retrying... (${context.retryCount}/${context.maxRetries})`);
      context.setState(reconnectingState);
    } else {
      console.log("Max retries reached");
      context.setState(disconnectedState);
      context.emit("error", error);
    }
  },
};

/**
 * Connected state
 */
const connectedState: ConnectionState = {
  name: "Connected",
  
  async connect(context) {
    console.log("Already connected");
  },
  
  disconnect(context) {
    console.log("Disconnecting...");
    context.setState(disconnectedState);
    context.emit("disconnected");
  },
  
  send(context, data) {
    console.log(`Sending: ${JSON.stringify(data)}`);
    context.data.push({ sent: data, timestamp: new Date() });
    context.emit("sent", data);
  },
  
  handleError(context, error) {
    console.log(`Connection error: ${error.message}`);
    context.setState(reconnectingState);
  },
};

/**
 * Reconnecting state
 */
const reconnectingState: ConnectionState = {
  name: "Reconnecting",
  
  async connect(context) {
    await new Promise(r => setTimeout(r, 1000 * context.retryCount));
    
    console.log(`Reconnecting to ${context.url}...`);
    
    // Simulate reconnection
    const success = Math.random() > 0.5;
    
    if (success) {
      context.retryCount = 0;
      context.setState(connectedState);
      context.emit("reconnected");
    } else {
      connectingState.handleError(context, new Error("Reconnection failed"));
    }
  },
  
  disconnect(context) {
    console.log("Reconnection cancelled");
    context.retryCount = 0;
    context.setState(disconnectedState);
  },
  
  send(context, data) {
    console.log("Cannot send: Reconnecting...");
  },
  
  handleError(context, error) {
    connectingState.handleError(context, error);
  },
};

/**
 * Connection interface
 */
interface Connection {
  connect: () => Promise<void>;
  disconnect: () => void;
  send: (data: unknown) => void;
  getState: () => string;
  onEvent: (event: string, handler: (data?: unknown) => void) => void;
}

/**
 * Create a connection with state management
 */
const createConnection = (url: string, maxRetries = 3): Connection => {
  let currentState: ConnectionState = disconnectedState;
  const eventHandlers = new Map<string, ((data?: unknown) => void)[]>();
  
  const context: ConnectionContext = {
    url,
    retryCount: 0,
    maxRetries,
    data: [],
    setState(state) {
      currentState = state;
      console.log(`[Connection state → ${state.name}]`);
    },
    emit(event, data) {
      const handlers = eventHandlers.get(event);
      if (handlers) {
        for (const handler of handlers) {
          handler(data);
        }
      }
    },
  };

  return {
    connect: () => currentState.connect(context),
    disconnect: () => currentState.disconnect(context),
    send: (data) => currentState.send(context, data),
    getState: () => currentState.name,
    onEvent(event, handler) {
      if (!eventHandlers.has(event)) {
        eventHandlers.set(event, []);
      }
      eventHandlers.get(event)!.push(handler);
    },
  };
};

// Usage
const connection = createConnection("wss://api.example.com");

console.log("\n--- Connection State Demo ---\n");

// Set up event handlers
connection.onEvent("connected", () => console.log("✓ Connected event"));
connection.onEvent("disconnected", () => console.log("✗ Disconnected event"));
connection.onEvent("reconnected", () => console.log("↻ Reconnected event"));
connection.onEvent("error", (err) => console.log(`! Error: ${err}`));

// Connect
await connection.connect();

// Try sending if connected
if (connection.getState() === "Connected") {
  connection.send({ type: "hello", message: "Hello, server!" });
  connection.send({ type: "data", value: 42 });
}

// Disconnect
connection.disconnect();

console.log(`\nFinal state: ${connection.getState()}`);
/**
 * Order data
 */
interface OrderData {
  id: string;
  items: { productId: string; name: string; quantity: number; price: number }[];
  customerId: string;
  shippingAddress: string;
  total: number;
  createdAt: Date;
  paidAt?: Date;
  shippedAt?: Date;
  deliveredAt?: Date;
  cancelledAt?: Date;
}

/**
 * Order context
 */
interface OrderContext {
  data: OrderData;
  setState: (state: OrderState) => void;
  notify: (event: string, data?: unknown) => void;
}

/**
 * Order state interface
 */
interface OrderState {
  name: string;
  pay: (context: OrderContext, paymentInfo: { method: string; transactionId: string }) => Promise<void>;
  ship: (context: OrderContext, trackingNumber: string) => void;
  deliver: (context: OrderContext) => void;
  cancel: (context: OrderContext, reason: string) => void;
  refund: (context: OrderContext) => Promise<void>;
}

/**
 * Created state - order just placed
 */
const createdState: OrderState = {
  name: "Created",
  
  async pay(context, paymentInfo) {
    console.log(`Processing payment via ${paymentInfo.method}...`);
    await new Promise(r => setTimeout(r, 500));
    
    context.data.paidAt = new Date();
    console.log(`Payment successful: ${paymentInfo.transactionId}`);
    context.setState(paidState);
    context.notify("payment_received", { orderId: context.data.id });
  },
  
  ship(context, trackingNumber) {
    console.log("Cannot ship: Payment not received");
  },
  
  deliver(context) {
    console.log("Cannot deliver: Order not shipped");
  },
  
  cancel(context, reason) {
    context.data.cancelledAt = new Date();
    console.log(`Order cancelled: ${reason}`);
    context.setState(cancelledState);
    context.notify("order_cancelled", { orderId: context.data.id, reason });
  },
  
  async refund(context) {
    console.log("Cannot refund: No payment to refund");
  },
};

/**
 * Paid state - payment received
 */
const paidState: OrderState = {
  name: "Paid",
  
  async pay(context, paymentInfo) {
    console.log("Order already paid");
  },
  
  ship(context, trackingNumber) {
    context.data.shippedAt = new Date();
    console.log(`Order shipped with tracking: ${trackingNumber}`);
    context.setState(shippedState);
    context.notify("order_shipped", { orderId: context.data.id, trackingNumber });
  },
  
  deliver(context) {
    console.log("Cannot deliver: Order not shipped");
  },
  
  cancel(context, reason) {
    // Can still cancel before shipping, but need refund
    context.data.cancelledAt = new Date();
    console.log(`Order cancelled before shipping: ${reason}`);
    console.log("Initiating refund...");
    context.setState(cancelledState);
    context.notify("refund_initiated", { orderId: context.data.id });
  },
  
  async refund(context) {
    console.log("Processing refund...");
    await new Promise(r => setTimeout(r, 500));
    context.data.cancelledAt = new Date();
    context.setState(cancelledState);
    context.notify("refund_completed", { orderId: context.data.id });
  },
};

/**
 * Shipped state - in transit
 */
const shippedState: OrderState = {
  name: "Shipped",
  
  async pay(context, paymentInfo) {
    console.log("Order already paid");
  },
  
  ship(context, trackingNumber) {
    console.log("Order already shipped");
  },
  
  deliver(context) {
    context.data.deliveredAt = new Date();
    console.log("Order delivered!");
    context.setState(deliveredState);
    context.notify("order_delivered", { orderId: context.data.id });
  },
  
  cancel(context, reason) {
    console.log("Cannot cancel: Order already shipped. Request return instead.");
  },
  
  async refund(context) {
    console.log("Cannot refund shipped order. Wait for delivery and request return.");
  },
};

/**
 * Delivered state
 */
const deliveredState: OrderState = {
  name: "Delivered",
  
  async pay(context, paymentInfo) {
    console.log("Order already paid");
  },
  
  ship(context, trackingNumber) {
    console.log("Order already delivered");
  },
  
  deliver(context) {
    console.log("Order already delivered");
  },
  
  cancel(context, reason) {
    console.log("Cannot cancel: Order already delivered. Request return.");
  },
  
  async refund(context) {
    // Refund after delivery (return process)
    console.log("Processing return and refund...");
    await new Promise(r => setTimeout(r, 500));
    context.setState(returnedState);
    context.notify("return_completed", { orderId: context.data.id });
  },
};

/**
 * Cancelled state
 */
const cancelledState: OrderState = {
  name: "Cancelled",
  
  async pay(context, paymentInfo) {
    console.log("Cannot pay: Order is cancelled");
  },
  
  ship(context, trackingNumber) {
    console.log("Cannot ship: Order is cancelled");
  },
  
  deliver(context) {
    console.log("Cannot deliver: Order is cancelled");
  },
  
  cancel(context, reason) {
    console.log("Order already cancelled");
  },
  
  async refund(context) {
    console.log("Refund already processed");
  },
};

/**
 * Returned state
 */
const returnedState: OrderState = {
  name: "Returned",
  
  async pay(context, paymentInfo) {
    console.log("Cannot pay: Order was returned");
  },
  
  ship(context, trackingNumber) {
    console.log("Cannot ship: Order was returned");
  },
  
  deliver(context) {
    console.log("Cannot deliver: Order was returned");
  },
  
  cancel(context, reason) {
    console.log("Cannot cancel: Order was returned");
  },
  
  async refund(context) {
    console.log("Refund already completed for returned order");
  },
};

/**
 * Order interface
 */
interface Order {
  pay: (paymentInfo: { method: string; transactionId: string }) => Promise<void>;
  ship: (trackingNumber: string) => void;
  deliver: () => void;
  cancel: (reason: string) => void;
  refund: () => Promise<void>;
  getState: () => string;
  getData: () => OrderData;
}

/**
 * Create an order
 */
const createOrder = (
  items: OrderData["items"],
  customerId: string,
  shippingAddress: string
): Order => {
  let currentState: OrderState = createdState;
  
  const context: OrderContext = {
    data: {
      id: `order_${Date.now()}`,
      items,
      customerId,
      shippingAddress,
      total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      createdAt: new Date(),
    },
    setState(state) {
      currentState = state;
      console.log(`[Order ${context.data.id} → ${state.name}]`);
    },
    notify(event, data) {
      console.log(`📧 Event: ${event}`, data);
    },
  };

  return {
    pay: (info) => currentState.pay(context, info),
    ship: (tracking) => currentState.ship(context, tracking),
    deliver: () => currentState.deliver(context),
    cancel: (reason) => currentState.cancel(context, reason),
    refund: () => currentState.refund(context),
    getState: () => currentState.name,
    getData: () => ({ ...context.data }),
  };
};

// Usage
console.log("\n--- Order Processing Demo ---\n");

const order = createOrder(
  [
    { productId: "prod-1", name: "TypeScript Book", quantity: 1, price: 49.99 },
    { productId: "prod-2", name: "Coffee Mug", quantity: 2, price: 14.99 },
  ],
  "customer-123",
  "123 Main St, City, Country"
);

console.log("Order created:", order.getData());

// Try to ship without payment
order.ship("TRACK123");

// Pay for order
await order.pay({ method: "credit_card", transactionId: "txn_abc123" });

// Ship order
order.ship("TRACK-XYZ-789");

// Deliver
order.deliver();

// Try to cancel after delivery
order.cancel("Changed my mind");

console.log("\nFinal order state:", order.getState());
console.log("Order data:", order.getData());

When to Use


State vs Strategy

AspectStateStrategy
PurposeObject behavior based on stateInterchangeable algorithms
AwarenessStates may know about each otherStrategies are independent
TransitionsStates can trigger transitionsStrategies don't transition
CouplingContext knows current stateContext uses one strategy

Summary

Key Takeaway: State pattern encapsulates state-specific behavior into separate objects, making state transitions explicit and the code more maintainable than large conditional blocks.

Pros

  • ✅ Single Responsibility: State-specific code in one place
  • ✅ Open/Closed: Add states without changing context
  • ✅ Explicit transitions: State changes are clear
  • ✅ Eliminates conditionals: No big switch statements

Cons

  • ❌ Overkill for few states
  • ❌ Many small classes/objects
  • ❌ States may need context access

On this page