Loading learning content...
The State Pattern creates objects that embody states. But states alone are inert—what brings a state machine to life is transitions. Transitions define how and when an object moves from one state to another, what triggers these movements, and what happens during the change.
Transitions are where business rules live. An order can't transition from CREATED to SHIPPED—it must go through PAID and PROCESSING first. A connection can't move from CLOSED to ESTABLISHED without completing the handshake sequence. A document can't be PUBLISHED without first being APPROVED.
Understanding transitions deeply is essential for building robust, correct state machines. In this page, we'll explore every facet of state transitions—from basic mechanics to sophisticated patterns for complex scenarios.
By the end of this page, you will master state transition mechanics including: who should own transition logic (states vs. context), guarded transitions with preconditions, transition actions and side effects, event-driven transitions, transition validation and error handling, and strategies for complex multi-step transitions.
A state transition is the movement of an object from one state to another, typically triggered by an event or operation. Every transition has four key components:
In UML state machine notation, transitions are often expressed as:
SourceState --[trigger/action]--> TargetState
For example:
HAS_COINS --[selectProduct(valid)/dispenseProduct]--> DISPENSING
This means: when in HAS_COINS state, if selectProduct is called with a valid selection, execute dispenseProduct and transition to DISPENSING.
Guards and conditional transitions:
Transitions can be guarded—they only occur if a condition is met. The guard is evaluated when the trigger occurs; if it's false, the transition doesn't happen.
HAS_COINS --[selectProduct/balance >= price && stock > 0/dispense]--> DISPENSING
HAS_COINS --[selectProduct/balance < price]--> HAS_COINS // Stay, inform user
Guards are essential for modeling business rules about when transitions are valid.
In well-designed state machines, the same trigger from the same state should always produce the same result (determinism). If you find yourself needing random or non-deterministic transitions, reconsider whether your states are properly modeled—you may need additional states to capture the conditions that determine different outcomes.
One of the key design decisions in the State Pattern is where transition logic resides. There are three main approaches, each with distinct trade-offs.
123456789101112131415161718192021222324
// The state decides when to transitionclass HasCoinsState implements VendingMachineState { selectProduct(machine: VendingMachine, productId: string): string { const price = machine.getPrice(productId); if (machine.getBalance() >= price && machine.getStock(productId) > 0) { // State directly triggers the transition machine.setState(new DispensingState(productId)); return "Dispensing..."; } return "Insufficient balance or out of stock."; }} // Advantages:// - State encapsulates its own knowledge// - Easy to understand each state in isolation// - New states don't require context changes // Disadvantages:// - States must know about other states (coupling)// - Transition logic scattered across state classes// - Hard to see full transition graph without reading all states123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// States return transition requests; context decidesinterface TransitionRequest { targetState: string; payload?: any;} class HasCoinsState implements VendingMachineState { selectProduct(machine: VendingMachine, productId: string): TransitionRequest | null { const price = machine.getPrice(productId); if (machine.getBalance() >= price && machine.getStock(productId) > 0) { // State requests transition, doesn't execute it return { targetState: "DISPENSING", payload: { productId } }; } return null; // No transition }} class VendingMachine { private stateFactory: StateFactory; selectProduct(productId: string): string { const request = this.state.selectProduct(this, productId); if (request) { // Context validates and executes the transition if (this.isValidTransition(this.state.getName(), request.targetState)) { const newState = this.stateFactory.create(request.targetState, request.payload); this.setState(newState); return "Product selected."; } else { console.log("Invalid transition blocked"); } } return "Selection failed."; } private isValidTransition(from: string, to: string): boolean { // Central validation logic const validTransitions = { "IDLE": ["HAS_COINS"], "HAS_COINS": ["IDLE", "DISPENSING"], "DISPENSING": ["IDLE", "HAS_COINS"], // ... }; return validTransitions[from]?.includes(to) ?? false; }} // Advantages:// - All transition rules in one place// - Easy to validate, log, or audit transitions// - States are decoupled from each other // Disadvantages:// - Context becomes more complex// - States lose some encapsulation// - Changes to transitions require modifying context1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Transitions defined declarativelyconst transitionTable: TransitionDefinition[] = [ { from: "IDLE", trigger: "insertCoin", to: "HAS_COINS", action: (ctx, payload) => ctx.addToBalance(payload.amount) }, { from: "HAS_COINS", trigger: "selectProduct", to: "DISPENSING", guard: (ctx, payload) => ctx.getBalance() >= ctx.getPrice(payload.productId) && ctx.getStock(payload.productId) > 0, action: (ctx, payload) => ctx.dispense(payload.productId) }, { from: "HAS_COINS", trigger: "refund", to: "IDLE", action: (ctx) => ctx.returnBalance() }, // ... all transitions defined here]; class StateMachine { private currentState: string; private transitions: TransitionDefinition[]; constructor(initialState: string, transitions: TransitionDefinition[]) { this.currentState = initialState; this.transitions = transitions; } dispatch(trigger: string, payload?: any): boolean { const transition = this.transitions.find(t => t.from === this.currentState && t.trigger === trigger && (t.guard?.(this.context, payload) ?? true) ); if (transition) { transition.action?.(this.context, payload); this.currentState = transition.to; return true; } return false; }} // Advantages:// - Complete transition graph visible in one place// - Transitions can be loaded from config files// - Easy to visualize, test, and modify// - Supports tooling (diagram generation, validation) // Disadvantages:// - More infrastructure needed// - Less natural OOP design// - Actions become callbacks, not methodsFor most applications, start with state-owned transitions (decentralized). It's the most natural fit for the State Pattern. Move to centralized or externalized approaches when: you need global transition validation, transitions are defined by non-developers, or you need runtime-modifiable transitions.
Transitions often need to perform actions beyond simply changing state. These actions can occur at different points in the transition lifecycle:
Understanding when to use each type is crucial for correctly modeling behavior.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
interface State { getName(): string; // Lifecycle hooks onEnter?(context: Context): void; onExit?(context: Context): void; // State-specific operations handle(context: Context, event: Event): void;} class PaidState implements State { getName(): string { return "PAID"; } onEnter(context: Order): void { // Entry action: Things that happen when entering PAID context.sendConfirmationEmail(); context.reserveInventory(); context.schedulePaymentCapture(); console.log("Entered PAID state - confirmation sent"); } onExit(context: Order): void { // Exit action: Cleanup when leaving PAID context.capturePayment(); // Finalize the payment hold console.log("Exiting PAID state - payment captured"); } handle(context: Order, event: Event): void { if (event.type === "START_PROCESSING") { // Transition action: happens during the transition context.assignToWarehouse(); context.createPickingList(); // Trigger the transition context.setState(new ProcessingState()); } }} class Context { private state: State; setState(newState: State): void { // Execute exit actions on current state this.state.onExit?.(this); // Transition complete, update state reference const oldState = this.state; this.state = newState; // Execute entry actions on new state this.state.onEnter?.(this); console.log(`Transitioned: ${oldState.getName()} → ${newState.getName()}`); }}| Action Type | When to Use | Examples |
|---|---|---|
| Exit Actions | Cleanup, resource release, finalization at source state | Release locks, save state, complete pending operations |
| Transition Actions | Logic that depends on both source and target state | Logging the transition, calculating durations, audit trails |
| Entry Actions | Initialization, resource acquisition at target state | Start timers, acquire locks, send notifications, schedule tasks |
What happens if an entry action fails? You're in a problematic state: you've left the source but failed to enter the target. Design strategies include: making actions idempotent, using transactions, or designing compensating actions. Never leave the state machine in an inconsistent state.
Guarded transitions only execute when a precondition is satisfied. Guards are essential for modeling business rules that constrain state changes based on runtime conditions.
12345678910111213141516171819202122232425262728293031323334353637
class UnderReviewState implements DocumentState { // Guard: Document can only be approved if all reviewers have approved approve(doc: Document, reviewer: Reviewer): string { // Check the guard condition if (!doc.allReviewersApproved()) { return "Cannot approve: not all reviewers have signed off."; } // Additional guards can be chained if (doc.hasUnresolvedComments()) { return "Cannot approve: unresolved comments exist."; } if (!reviewer.hasApprovalAuthority()) { return "Cannot approve: insufficient authority."; } // All guards passed - execute transition doc.setApprover(reviewer); doc.setApprovedAt(new Date()); doc.setState(new ApprovedState()); return "Document approved successfully."; } reject(doc: Document, reason: string): string { // Guard: Must provide a reason for rejection if (!reason || reason.trim().length < 10) { return "Cannot reject: meaningful reason required (min 10 chars)."; } doc.setRejectionReason(reason); doc.setState(new DraftState()); // Back to draft for revision return "Document rejected. Author notified."; }}Guard design principles:
Guards should be side-effect free — They evaluate conditions but don't change state. A guard that modifies data creates unpredictable behavior.
Guards should be fast — They're evaluated on every trigger attempt. Expensive database queries in guards slow down every operation.
Guards should be deterministic — Given the same context state, a guard should always return the same result.
Failed guards should be informative — Instead of boolean results, return error messages explaining why the guard failed.
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Instead of simple boolean guards, use descriptive resultsinterface GuardResult { allowed: boolean; reason?: string;} class PaymentPendingState implements OrderState { private canTransitionToPaid(order: Order): GuardResult { if (order.getPaymentStatus() !== "CAPTURED") { return { allowed: false, reason: "Payment not yet captured by processor" }; } if (order.getFraudScore() > 80) { return { allowed: false, reason: "Order flagged for fraud review" }; } if (order.hasInventoryConflict()) { return { allowed: false, reason: "Inventory no longer available" }; } return { allowed: true }; } confirmPayment(order: Order): string { const guardResult = this.canTransitionToPaid(order); if (!guardResult.allowed) { order.logBlockedTransition("PAID", guardResult.reason); return `Payment confirmation blocked: ${guardResult.reason}`; } order.setState(new PaidState()); return "Payment confirmed, order processing."; }}For complex guard conditions, consider extracting guards into separate classes or functions. This enables reuse (same guard for multiple transitions), easier testing (test guards in isolation), and clearer naming (the guard's name explains the business rule).
In many systems, transitions are triggered by events rather than direct method calls. This approach decouples the trigger mechanism from the state logic and enables more flexible event sourcing and processing architectures.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
// Events are first-class objects with types and payloadsinterface StateEvent { type: string; payload?: any; timestamp: Date; source?: string;} // Each state handles events and returns the next stateinterface State { handleEvent(context: Context, event: StateEvent): State | null;} class IdleState implements State { handleEvent(machine: VendingMachine, event: StateEvent): State | null { switch (event.type) { case "COIN_INSERTED": machine.addToBalance(event.payload.amount); return new HasCoinsState(); case "ADMIN_RESTOCK": machine.restockInventory(event.payload.items); return null; // Stay in current state case "POWER_OFF": return new PoweredOffState(); default: console.log(`IDLE: Ignoring unknown event ${event.type}`); return null; } }} class StateMachine { private state: State; private eventQueue: StateEvent[] = []; dispatch(event: StateEvent): void { console.log(`[Event] ${event.type} at ${event.timestamp}`); const nextState = this.state.handleEvent(this.context, event); if (nextState !== null) { this.transition(nextState); } } // Support for async event processing async dispatchAsync(event: StateEvent): Promise<void> { this.eventQueue.push(event); await this.processQueue(); } private async processQueue(): Promise<void> { while (this.eventQueue.length > 0) { const event = this.eventQueue.shift()!; this.dispatch(event); } }} // Usagemachine.dispatch({ type: "COIN_INSERTED", payload: { amount: 100 }, timestamp: new Date() });machine.dispatch({ type: "PRODUCT_SELECTED", payload: { id: "COLA" }, timestamp: new Date() });Event-driven systems must handle event ordering carefully. What if PAYMENT_RECEIVED arrives before ORDER_CREATED due to race conditions? Design states to handle out-of-order events gracefully, or use infrastructure that guarantees ordering.
Real-world state machines often have complex transition requirements that go beyond simple A→B transitions. Let's examine advanced patterns for handling these scenarios.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// ProcessingState contains sub-statesabstract class ProcessingSubState implements State { abstract getName(): string; abstract handle(order: Order, event: Event): State | null; // Common behavior for all processing sub-states cancel(order: Order): State { order.releaseInventory(); order.initiateRefund(); return new CancelledState(); }} class PickingState extends ProcessingSubState { getName() { return "PROCESSING.PICKING"; } handle(order: Order, event: Event): State | null { if (event.type === "PICKING_COMPLETE") { return new PackingState(); } if (event.type === "ITEM_UNAVAILABLE") { order.notifyCustomer("Item unavailable"); return new BackorderedState(); } return null; }} class PackingState extends ProcessingSubState { getName() { return "PROCESSING.PACKING"; } handle(order: Order, event: Event): State | null { if (event.type === "PACKAGING_COMPLETE") { return new ShippingState(); } return null; }} class ShippingState extends ProcessingSubState { getName() { return "PROCESSING.SHIPPING"; } handle(order: Order, event: Event): State | null { if (event.type === "SHIPPED") { return new ShippedState(); // Exit processing hierarchy } return null; }} // The parent state delegates to current sub-stateclass ProcessingState implements State { private subState: ProcessingSubState; constructor() { this.subState = new PickingState(); // Initial sub-state } getName() { return this.subState.getName(); } handle(order: Order, event: Event): State | null { // Try sub-state first const nextSubState = this.subState.handle(order, event); if (nextSubState instanceof ProcessingSubState) { this.subState = nextSubState; return null; // Stay in ProcessingState } return nextSubState; // Exit to a different top-level state } cancel(order: Order): State { return this.subState.cancel(order); }}1234567891011121314151617181920212223242526272829303132333435
// Self-transition: State transitions to itself, triggering actionsclass WaitingForPaymentState implements State { private retryCount: number = 0; private readonly MAX_RETRIES = 3; handle(order: Order, event: Event): State | null { if (event.type === "PAYMENT_RETRY") { this.retryCount++; if (this.retryCount >= this.MAX_RETRIES) { order.notifyPaymentFailed(); return new CancelledState(); } // Self-transition: return same state type (new instance) // This triggers exit/entry actions again return new WaitingForPaymentState(); // Fresh retry counter } if (event.type === "PAYMENT_SUCCESS") { return new PaidState(); } return null; } onEnter(order: Order): void { order.attemptPaymentCapture(); order.scheduleRetryTimer(30000); // 30 second retry } onExit(order: Order): void { order.cancelRetryTimer(); }}These advanced patterns add complexity. Use them when genuinely needed—when the domain truly has hierarchical states or parallel processes. Don't introduce complexity to handle simple scenarios. Many state machines work perfectly with flat state structures.
Robust state machines must handle invalid transitions gracefully. What happens when code attempts a transition that shouldn't occur? How do we ensure the state machine never reaches an illegal state?
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Strategy 1: Silent rejection (return null/same state)class ShippedState implements State { cancel(order: Order): State { // Can't cancel a shipped order - stay in current state console.log("Cannot cancel shipped order"); return this; // Return same state, no transition }} // Strategy 2: Exception on invalid transitionclass DeliveredState implements State { cancel(order: Order): never { throw new IllegalStateTransitionError( "DELIVERED", "CANCELLED", "Cannot cancel after delivery. Use return process instead." ); }} // Strategy 3: Validation layer in contextclass OrderContext { private readonly validTransitions: Map<string, Set<string>> = new Map([ ["CREATED", new Set(["PAYMENT_PENDING", "CANCELLED"])], ["PAYMENT_PENDING", new Set(["PAID", "CANCELLED"])], ["PAID", new Set(["PROCESSING", "CANCELLED"])], ["PROCESSING", new Set(["SHIPPED", "CANCELLED"])], ["SHIPPED", new Set(["DELIVERED", "RETURN_REQUESTED"])], ["DELIVERED", new Set(["RETURN_REQUESTED", "COMPLETED"])], // Terminal states have no valid transitions ["CANCELLED", new Set()], ["COMPLETED", new Set()], ]); setState(newState: State): void { const currentStateName = this.state.getName(); const newStateName = newState.getName(); const allowedTargets = this.validTransitions.get(currentStateName); if (!allowedTargets?.has(newStateName)) { const error = new IllegalStateTransitionError( currentStateName, newStateName, `Transition not allowed. Valid targets from ${currentStateName}: ${[...allowedTargets || []].join(", ")}` ); // Log for monitoring this.logger.error("Invalid state transition attempted", { from: currentStateName, to: newStateName, orderId: this.orderId, }); throw error; } // Valid transition - proceed this.executeTransition(newState); }}The worst outcome is proceeding with a transition that leaves the system in an inconsistent state. If guards fail, entry actions fail, or invariants break—stop immediately. A thrown exception is infinitely better than corrupted data.
State transitions are where business logic lives, making them critical to test thoroughly. The State Pattern's structure enables systematic testing strategies.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
describe('Order State Transitions', () => { // Test 1: Valid transitions work correctly describe('valid transitions', () => { it('should transition from CREATED to PAYMENT_PENDING', () => { const order = new Order(); expect(order.getStateName()).toBe("CREATED"); order.submitForPayment(); expect(order.getStateName()).toBe("PAYMENT_PENDING"); }); it('should execute entry actions on transition', () => { const order = new Order(); const notificationSpy = jest.spyOn(order, 'sendPaymentRequest'); order.submitForPayment(); expect(notificationSpy).toHaveBeenCalledTimes(1); }); }); // Test 2: Invalid transitions are rejected describe('invalid transitions', () => { it('should reject transition from SHIPPED to CANCELLED', () => { const order = createOrderInState("SHIPPED"); expect(() => order.cancel()).toThrow(IllegalStateTransitionError); expect(order.getStateName()).toBe("SHIPPED"); // Unchanged }); it('should not execute actions on rejected transition', () => { const order = createOrderInState("SHIPPED"); const refundSpy = jest.spyOn(order, 'initiateRefund'); try { order.cancel(); } catch (e) { /* expected */ } expect(refundSpy).not.toHaveBeenCalled(); }); }); // Test 3: Guards prevent invalid transitions describe('guarded transitions', () => { it('should reject approval without all reviewer sign-offs', () => { const doc = createDocumentInState("UNDER_REVIEW"); doc.reviewers = [ { approved: true }, { approved: false }, // Not all approved ]; const result = doc.approve(); expect(result.success).toBe(false); expect(result.reason).toContain("not all reviewers"); expect(doc.getStateName()).toBe("UNDER_REVIEW"); }); }); // Test 4: Complete path testing describe('transition paths', () => { it('should support complete happy path', () => { const order = new Order(); const transitions = [ () => order.submitForPayment(), () => order.confirmPayment(), () => order.startProcessing(), () => order.ship(), () => order.confirmDelivery(), ]; const expectedStates = [ "PAYMENT_PENDING", "PAID", "PROCESSING", "SHIPPED", "DELIVERED" ]; transitions.forEach((transition, i) => { transition(); expect(order.getStateName()).toBe(expectedStates[i]); }); }); }); // Test 5: State-specific behavior describe('state-specific behavior', () => { it('should allow editing in DRAFT state', () => { const doc = createDocumentInState("DRAFT"); const result = doc.edit("New content"); expect(result.success).toBe(true); expect(doc.getContent()).toBe("New content"); }); it('should reject editing in PUBLISHED state', () => { const doc = createDocumentInState("PUBLISHED"); const result = doc.edit("Modified content"); expect(result.success).toBe(false); expect(doc.getContent()).not.toBe("Modified content"); }); });}); // Helper to create objects in specific states for testingfunction createOrderInState(stateName: string): Order { const order = new Order(); // Use internal API to set state directly (for testing only) order['state'] = StateFactory.create(stateName); return order;}For each state, test: (1) all valid outgoing transitions, (2) all invalid transition attempts, (3) all operations with their state-specific behavior, and (4) entry/exit actions. For the state machine overall, test key paths through the graph representing real user journeys.
State transitions are the dynamics that bring state machines to life. We've explored the full spectrum of transition concepts and patterns:
What's next:
With solid understanding of the State Pattern's structure and transitions, we're ready to explore a common source of confusion: State Pattern vs. Strategy Pattern. Both patterns use composition and polymorphism, both involve interchangeable objects, but they solve fundamentally different problems. Understanding the distinction sharpens your ability to choose the right pattern.
You now have deep knowledge of state transitions—the mechanics, patterns, and practices that make state machines work correctly. Next, we'll clarify the distinction between State and Strategy patterns, two patterns that share structural similarities but serve different purposes.