State
Advanced · 13 min read · ▶ live playground · ✦ checkpoint
A vending machine accepts a coin while it has no stock, or dispenses twice after one selection. The bug is rarely one bad if; it comes from three methods carrying separate copies of which operations are legal in each mode.
The State pattern gives each meaningful condition its own object, allowing a context to change behavior when its internal state changes. It localizes transition rules, but the number of classes grows with the state model and shared data must remain coordinated through the context.
Before: every operation repeats the transition table
String states begin compactly. As operations multiply, each method needs a branch for every mode and must agree on the same legal transitions:
type MachineMode = "noCoin" | "hasCoin" | "sold";
class VendingMachine {
private mode: MachineMode = "noCoin";
private stock = 0;
private selectedItem: string | null = null;
public insertCoin(): string {
switch (this.mode) {
case "noCoin":
this.mode = "hasCoin";
return "Coin accepted";
case "hasCoin":
return "Coin returned";
case "sold":
return "Please wait";
}
}
public selectItem(item: string): string {
switch (this.mode) {
case "noCoin":
return "Insert a coin first";
case "hasCoin":
this.selectedItem = item;
this.mode = "sold"; // Bug: stock was never checked.
return `Selected: ${item}`;
case "sold":
return "An item is already selected";
}
}
public dispense(): string {
switch (this.mode) {
case "noCoin":
return "Nothing to dispense";
case "hasCoin":
return "Select an item first";
case "sold":
this.stock -= 1; // Illegal when stock is already zero.
this.selectedItem = null;
// Bug: mode stays "sold", so another call dispenses again.
return "Item dispensed";
}
}
}The hasCoin meaning is spread across all three methods. Adding a soldOut or maintenance mode means editing every switch, and one missing assignment can leave mode inconsistent with selectedItem or stock.
A transition table lists each current state, event, and next state. It is still useful for designing the lifecycle; the problem is encoding that table as repeated branches throughout the context.
After: make each condition responsible for its behavior
A state is an object that represents one condition of a context and handles operations for that condition. A transition is a move from one state to another after an event. The context is the object whose behavior changes while it holds the current state; here, that context is VendingMachine.
The machine delegates every public operation to one VendingState. A state handles the legal action, rejects an illegal one, and installs the next state when the lifecycle advances.
class diagram
VendingMachine delegates behavior to its current state
- 1VendingState lists every operation whose behavior depends on the machine's condition.
- 2VendingMachine is the context; it owns one current state and delegates public operations.
- 3NoCoinState accepts a coin, rejects selection, and transitions the context forward.
- 4HasCoinState accepts a selection and installs SoldState as the next behavior.
- 5SoldState dispenses the chosen item, then returns the context to NoCoinState.
Here, SoldState means a sale has been approved and is waiting to dispense. It does not mean that the machine is out of stock.
The same object changes which implementation receives the next call. That runtime flow is easier to see as a timeline:
sequence diagram
One purchase moves through three state objects
- 1insertCoin() delegates to NoCoinState, which accepts the coin and installs HasCoinState.
- 2selectItem() reaches HasCoinState; selection moves the context to SoldState.
- 3dispense() runs in SoldState, releases the item, and returns the machine to NoCoinState.
The client always talks to VendingMachine. Only the machine knows which state object currently receives the delegated call.
interface VendingState {
insertCoin(machine: VendingMachine): void;
selectItem(machine: VendingMachine, item: string): void;
dispense(machine: VendingMachine): void;
}
class VendingMachine {
private state: VendingState = new NoCoinState();
private selectedItem: string | null = null;
public setState(state: VendingState): void {
this.state = state;
}
public rememberSelection(item: string): void {
this.selectedItem = item;
}
public releaseSelection(): string {
if (this.selectedItem === null) {
throw new Error("No selected item");
}
const item = this.selectedItem;
this.selectedItem = null;
return item;
}
public insertCoin(): void {
this.state.insertCoin(this);
}
public selectItem(item: string): void {
this.state.selectItem(this, item);
}
public dispense(): void {
this.state.dispense(this);
}
}
class NoCoinState implements VendingState {
public insertCoin(machine: VendingMachine): void {
console.log("Coin accepted");
machine.setState(new HasCoinState());
}
public selectItem(_machine: VendingMachine, _item: string): void {
console.log("Insert a coin first");
}
public dispense(_machine: VendingMachine): void {
console.log("Nothing to dispense");
}
}
class HasCoinState implements VendingState {
public insertCoin(_machine: VendingMachine): void {
console.log("Coin returned: one coin is enough");
}
public selectItem(machine: VendingMachine, item: string): void {
machine.rememberSelection(item);
console.log(`Selected: ${item}`);
machine.setState(new SoldState());
}
public dispense(_machine: VendingMachine): void {
console.log("Select an item first");
}
}
class SoldState implements VendingState {
public insertCoin(_machine: VendingMachine): void {
console.log("Please wait for the current item");
}
public selectItem(_machine: VendingMachine, _item: string): void {
console.log("An item is already selected");
}
public dispense(machine: VendingMachine): void {
const item = machine.releaseSelection();
console.log(`Dispensed: ${item}`);
machine.setState(new NoCoinState());
}
}
const machine = new VendingMachine();
machine.selectItem("Water");
machine.insertCoin();
machine.selectItem("Water");
machine.dispense();
machine.insertCoin();Insert a coin first Coin accepted Selected: Water Dispensed: Water Coin accepted
The first selection is rejected by NoCoinState; no branch in VendingMachine checks the condition. After a coin, HasCoinState owns the next rules. Selection installs SoldState, and dispensing returns the context to NoCoinState, which the final accepted coin confirms.
This core model assumes the requested item is available so the three-state purchase flow stays visible. A production machine adds inventory and a SoldOutState; the Module 8 vending-machine case study will force those requirements into the larger design.
State versus Strategy
The class diagram resembles the one in the Strategy lesson: a context holds an interface, and concrete implementations provide behavior. The collaboration tells them apart.
In Strategy, a client chooses an algorithm such as economy shipping. The active strategy usually stays in place until the client replaces it, and it does not select the next algorithm. In State, an operation reflects an internal condition and a state object advances the context to its next condition.
Ask one diagnostic question: “Would the object move between these behaviors as part of its own lifecycle?” If yes, State is the stronger description. If a caller chooses among equivalent ways to perform one task, Strategy is stronger.
When NOT to use it
For two stable conditions with one small difference, a boolean can be clearer. isLocked with a short guard does not need LockedState, UnlockedState, a state interface, and a transition method. The pattern earns its class count when several operations vary together and transitions form a meaningful lifecycle.
State can also scatter one workflow across many files. Reading a purchase may require jumping from NoCoinState to HasCoinState, through the context, and into SoldState. A transition table beside the code and focused lifecycle tests keep the whole model visible.
Shared data must be threaded through the context. If every state reaches into public fields or duplicates inventory data, the classes have separated behavior while weakening encapsulation. Expose narrow context operations such as rememberSelection() instead of turning the context into a bag of mutable fields.
The trade-off is local transition logic against more types and navigation. Use State when branches repeat across several operations, illegal transitions matter, and new conditions are likely to grow the matrix—not as a replacement for every if.
Checkpoint
Answer all three to mark this lesson complete
State localizes behavior that changes with an object's condition. The remaining behavioral patterns—Template Method, Iterator, Mediator, Memento, Chain of Responsibility, and Visitor—each localize a different collaboration concern.