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 State | Action | Next State | Condition |
|---|---|---|---|
| Draft | submit | Under Review | Content not empty |
| Draft | archive | Archived | โ |
| Under Review | approve | Approved | Reviewer has role |
| Under Review | reject | Rejected | โ |
| Approved | publish | Published | Publisher has role |
| Approved | reject | Rejected | โ |
| Rejected | edit | Draft | โ |
| Published | archive | Archived | โ |
| Published | edit | Draft (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:
| Aspect | State Pattern | Strategy Pattern |
|---|---|---|
| Who drives change | State objects themselves trigger transitions | Client/external code swaps strategies |
| Awareness | Context may not know which state is active | Client explicitly chooses the strategy |
| Coupling | States know about each other (to transition) | Strategies are independent of each other |
| Purpose | Manage internal state-dependent behavior | Swap interchangeable algorithms |
| Analogy | Media player modes (play/pause/stop cycling) | Compression format chosen by user |
โ Prosโ
- Eliminates Conditionals: No more
if/elseorswitchchains 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/elseorswitchstatements 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
๐ Related Patternsโ
- 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