Skip to main content

State Pattern ๐ŸŽ›๏ธ

Definition: The State pattern allows an object to alter its behavior when its internal state changes. The object will appear to change its class.

๐ŸŽฏ Intentโ€‹

Encapsulate state-specific behavior in separate classes and delegate work to the current state object. This eliminates sprawling conditional logic and makes state transitions explicit.

๐Ÿค” Problemโ€‹

A Document object goes through states like Draft โ†’ Under Review โ†’ Approved โ†’ Published. Without the State pattern, every method checks the current state with if/else or switch:

// โŒ Anti-pattern: state-driven conditionals everywhere
class Document {
state: string;

publish() {
if (this.state === 'draft') {
console.log('Cannot publish draft โ€” submit first');
} else if (this.state === 'approved') {
console.log('Publishing...');
this.state = 'published';
} else if (this.state === 'published') {
console.log('Already published');
}
// ...every method repeats this branching
}
}

As states grow, conditionals spread across every method. Adding a new state requires touching every method โ€” a maintenance nightmare.

๐Ÿ’ก Solutionโ€‹

Create a separate class for each state. The context holds a reference to the current state and delegates all state-dependent work to it. States handle transitions by setting the context's next state.

๐Ÿ—๏ธ Structureโ€‹

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Context โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ - state: State โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ + request(): void โ”‚ โ†’ state.handle(this)
โ”‚ + setState(state: State) โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ delegates to
โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ ยซinterfaceยป State โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ + handle(context): void โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ implements
โ”Œโ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”
โ–ผ โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ StateA โ”‚ โ”‚ StateB โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚+handle()โ”‚ โ”‚+handle()โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿ“Š State Transition Tableโ€‹

A formal way to document valid transitions:

Current StateActionNext StateCondition
DraftsubmitUnder ReviewContent not empty
DraftarchiveArchivedโ€”
Under ReviewapproveApprovedReviewer has role
Under ReviewrejectRejectedโ€”
ApprovedpublishPublishedPublisher has role
ApprovedrejectRejectedโ€”
RejectededitDraftโ€”
PublishedarchiveArchivedโ€”
PublishededitDraft (v2)Creates new version
Archivedโ€”โ€”Terminal state

๐Ÿ’ป Code Exampleโ€‹

Minimal TypeScript Implementationโ€‹

interface State {
play(player: MediaPlayer): void;
pause(player: MediaPlayer): void;
stop(player: MediaPlayer): void;
}

class PlayingState implements State {
play() {
console.log('โ–ถ๏ธ Already playing');
}
pause(p: MediaPlayer) {
console.log('โธ๏ธ Pausing');
p.setState(new PausedState());
}
stop(p: MediaPlayer) {
console.log('โน๏ธ Stopping');
p.setState(new StoppedState());
}
}

class PausedState implements State {
play(p: MediaPlayer) {
console.log('โ–ถ๏ธ Resuming');
p.setState(new PlayingState());
}
pause() {
console.log('โธ๏ธ Already paused');
}
stop(p: MediaPlayer) {
console.log('โน๏ธ Stopping');
p.setState(new StoppedState());
}
}

class StoppedState implements State {
play(p: MediaPlayer) {
console.log('โ–ถ๏ธ Starting playback');
p.setState(new PlayingState());
}
pause() {
console.log('โธ๏ธ Cannot pause โ€” already stopped');
}
stop() {
console.log('โน๏ธ Already stopped');
}
}

class MediaPlayer {
private state: State = new StoppedState();

setState(state: State): void {
console.log(`๐Ÿ”„ ${this.state.constructor.name} โ†’ ${state.constructor.name}`);
this.state = state;
}

play() {
this.state.play(this);
}
pause() {
this.state.pause(this);
}
stop() {
this.state.stop(this);
}
}

// Usage
const player = new MediaPlayer();
player.play(); // StoppedState โ†’ PlayingState
player.pause(); // PlayingState โ†’ PausedState
player.play(); // PausedState โ†’ PlayingState
player.stop(); // PlayingState โ†’ StoppedState

๐ŸŒŸ Real-World Examplesโ€‹

1. Document Workflow System (TypeScript)โ€‹

// ---- States ----
interface DocumentState {
edit(doc: Document): void;
submit(doc: Document): void;
approve(doc: Document, reviewer: string): void;
publish(doc: Document): void;
}

class DraftState implements DocumentState {
edit(doc: Document) {
doc.content += ' [edited]';
console.log(`โœ๏ธ Editing "${doc.title}"`);
}

submit(doc: Document) {
if (!doc.content.trim()) {
console.log('โŒ Cannot submit empty document');
return;
}
console.log(`๐Ÿ“ค Submitted for review`);
doc.transition(new UnderReviewState());
}

approve() {
this.invalid('approve');
}
publish() {
this.invalid('publish');
}

private invalid(action: string) {
console.log(`โŒ Cannot ${action} a draft`);
}
}

class UnderReviewState implements DocumentState {
edit() {
console.log('โŒ Cannot edit under review');
}
submit() {
console.log('โŒ Already under review');
}

approve(doc: Document, reviewer: string) {
console.log(`โœ… Approved by ${reviewer}`);
doc.transition(new ApprovedState());
}

publish() {
this.invalid('publish');
}
private invalid(action: string) {
console.log(`โŒ Cannot ${action} while under review`);
}
}

class ApprovedState implements DocumentState {
edit() {
console.log('โŒ Cannot edit approved document');
}
submit() {
console.log('โŒ Already approved');
}
approve() {
console.log('โŒ Already approved');
}

publish(doc: Document) {
console.log(`๐ŸŒ Published!`);
doc.transition(new PublishedState());
}
}

class PublishedState implements DocumentState {
edit(doc: Document) {
console.log('โœ๏ธ Creating new version');
doc.version++;
doc.transition(new DraftState());
}
submit() {
console.log('โŒ Already published');
}
approve() {
console.log('โŒ Already published');
}
publish() {
console.log('โŒ Already published');
}
}

// ---- Context ----
class Document {
private state: DocumentState = new DraftState();
content: string = '';
version: number = 1;

constructor(public title: string) {}

transition(newState: DocumentState): void {
console.log(
`๐Ÿ“‹ "${this.title}": ${this.state.constructor.name} โ†’ ${newState.constructor.name}`,
);
this.state = newState;
}

edit() {
this.state.edit(this);
}
submit() {
this.state.submit(this);
}
approve(reviewer: string = '') {
this.state.approve(this, reviewer);
}
publish() {
this.state.publish(this);
}
}

// Usage
const doc = new Document('API Design v2');
doc.edit();
doc.submit(); // Draft โ†’ UnderReview
doc.approve('Alice'); // UnderReview โ†’ Approved
doc.publish(); // Approved โ†’ Published
doc.edit(); // Published โ†’ Draft (v2)

2. React: useReducer as a State Machineโ€‹

React's useReducer naturally implements the State pattern for components:

import { useReducer } from 'react';

type FetchState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: string[] }
| { status: 'error'; error: string };

type FetchAction =
| { type: 'FETCH' }
| { type: 'RESOLVE'; data: string[] }
| { type: 'REJECT'; error: string }
| { type: 'RESET' };

const transitionTable: Record<FetchState['status'], FetchAction['type'][]> = {
idle: ['FETCH'],
loading: ['RESOLVE', 'REJECT'],
success: ['RESET'],
error: ['RESET', 'FETCH'],
};

function fetchReducer(state: FetchState, action: FetchAction): FetchState {
// Guard: reject invalid transitions
if (!transitionTable[state.status].includes(action.type)) {
console.warn(`Invalid transition: ${state.status} โ†’ ${action.type}`);
return state;
}

switch (action.type) {
case 'FETCH':
return { status: 'loading' };
case 'RESOLVE':
return { status: 'success', data: action.data };
case 'REJECT':
return { status: 'error', error: action.error };
case 'RESET':
return { status: 'idle' };
}
}

function useDataFetch() {
const [state, dispatch] = useReducer(fetchReducer, { status: 'idle' });

const fetchData = async () => {
dispatch({ type: 'FETCH' });
try {
const res = await fetch('/api/items');
const data = await res.json();
dispatch({ type: 'RESOLVE', data });
} catch (e) {
dispatch({ type: 'REJECT', error: (e as Error).message });
}
};

return { state, fetchData, reset: () => dispatch({ type: 'RESET' }) };
}

3. TCP Connection State Machineโ€‹

type TcpState = 'CLOSED' | 'LISTEN' | 'SYN_SENT' | 'SYN_RCVD' | 'ESTABLISHED';

const tcpTransitions: Record<TcpState, Partial<Record<string, TcpState>>> = {
CLOSED: { passiveOpen: 'LISTEN', activeOpen: 'SYN_SENT' },
LISTEN: { send: 'SYN_SENT', close: 'CLOSED' },
SYN_SENT: { receive: 'SYN_RCVD', close: 'CLOSED' },
SYN_RCVD: { acknowledge: 'ESTABLISHED', close: 'CLOSED' },
ESTABLISHED: { close: 'CLOSED' },
};

class TcpConnection {
constructor(private state: TcpState = 'CLOSED') {}

transition(action: string): void {
const next = tcpTransitions[this.state]?.[action];
if (!next) {
console.log(`โŒ Cannot ${action} while ${this.state}`);
return;
}
console.log(`${this.state} โ†’ ${next}`);
this.state = next;
}

getState(): TcpState {
return this.state;
}
}

โš ๏ธ Common Pitfallsโ€‹

1. State Classes Knowing Too Muchโ€‹

State classes should only handle transitions. Business logic belongs in the context:

// โŒ BAD: state class runs business logic directly
class PlayingState implements State {
stop(player: MediaPlayer) {
analytics.track('stop'); // side effect in state class
player.saveToDisk(); // business logic in state class
player.setState(new StoppedState());
}
}

// โœ… GOOD: delegate business logic to context
class PlayingState implements State {
stop(player: MediaPlayer) {
// state only handles transition; context handles side effects
player.stopPlayback();
player.setState(new StoppedState());
}
}

2. Context Exposing Too Much Internal Stateโ€‹

The context should expose only what states need โ€” not all internals:

// โœ… GOOD: context passes itself, states pull only what they need
class MediaPlayer {
private volume = 50;
private track = '';

// Expose via focused methods, not raw fields
getVolume(): number {
return this.volume;
}
setVolume(v: number) {
this.volume = v;
}
}

3. Duplicating State Instances (Memory Waste)โ€‹

If states are stateless, reuse singletons:

// โœ… GOOD: share stateless state instances
const playingState = new PlayingState();
const pausedState = new PausedState();
const stoppedState = new StoppedState();

class MediaPlayer {
setState(state: State) {
this.state = state;
} // accepts singleton
}

4. One State Class for Trivial Differencesโ€‹

If two states differ by only one behavior, consider a parameterized approach rather than two classes:

// โœ… BEFORE over-engineering, ask: are two classes really needed?
class LockedDoor {
open() {
console.log('โŒ Locked');
}
}
class UnlockedDoor {
open() {
console.log('โœ… Opened');
}
}

// Often a simple boolean or flag is more appropriate for trivial differences.

๐Ÿ”„ State vs Strategyโ€‹

Both patterns use composition/delegation and can look identical structurally. The difference is intent:

AspectState PatternStrategy Pattern
Who drives changeState objects themselves trigger transitionsClient/external code swaps strategies
AwarenessContext may not know which state is activeClient explicitly chooses the strategy
CouplingStates know about each other (to transition)Strategies are independent of each other
PurposeManage internal state-dependent behaviorSwap interchangeable algorithms
AnalogyMedia player modes (play/pause/stop cycling)Compression format chosen by user

โœ… Prosโ€‹

  • Eliminates Conditionals: No more if/else or switch chains checking state
  • Open/Closed Principle: Add new states without modifying existing state classes
  • Single Responsibility: Each state class handles one state's behavior
  • Explicit Transitions: State changes are visible and traceable
  • Testable States: States can be tested in isolation

โŒ Consโ€‹

  • Class Proliferation: Can result in many small classes for complex state machines
  • Overkill for Simple Cases: A boolean flag is often sufficient for 2-state scenarios
  • Coupling Between States: Concrete states reference each other to trigger transitions
  • Shared Data: Passing data between states requires care

๐ŸŽฏ When to Useโ€‹

  • Objects with state-dependent behavior: The same method behaves differently depending on current state
  • Complex conditional logic: Many if/else or switch statements driven by a state variable
  • Finite State Machines (FSM): Formal FSMs like TCP, game states, workflow engines
  • Workflow/approval systems: Documents, orders, tickets moving through stages
  • UI component modes: Edit vs view mode, loading vs loaded vs error states

๐ŸŽญ Variationsโ€‹

1. Table-Driven State Machineโ€‹

Instead of separate classes, use a transition table (see TCP example above). Simpler for small, well-defined FSMs. Less flexible for complex per-state behavior but great for data-driven transitions.

2. Enum-Based with Switch (Lightweight)โ€‹

For languages without classes or for simple cases, a single switch is acceptable:

type State = 'idle' | 'loading' | 'success' | 'error';

function transition(state: State, action: string): State {
const transitions: Record<State, Record<string, State>> = {
idle: { FETCH: 'loading' },
loading: { RESOLVE: 'success', REJECT: 'error' },
success: { RESET: 'idle' },
error: { FETCH: 'loading', RESET: 'idle' },
};
return transitions[state][action] ?? state;
}

3. XState (JavaScript FSM Library)โ€‹

For production-grade state machines with guards, side effects, and visualization:

import { createMachine, interpret } from 'xstate';

const playerMachine = createMachine({
id: 'player',
initial: 'stopped',
states: {
stopped: { on: { PLAY: 'playing' } },
playing: { on: { PAUSE: 'paused', STOP: 'stopped' } },
paused: { on: { PLAY: 'playing', STOP: 'stopped' } },
},
});

const service = interpret(playerMachine).start();
service.send('PLAY'); // stopped โ†’ playing
service.send('PAUSE'); // playing โ†’ paused
  • Strategy: Same structure, different intent โ€” Strategy is externally chosen, State is internally driven
  • Command: State transitions can be represented as command objects
  • Singleton: Idempotent states are often singletons to save memory
  • Flyweight: Share state objects across multiple contexts when states are stateless

๐Ÿ“š Further Readingโ€‹