The class carries a state field and every operation branches on it. Transitions are scattered across operations; legal state combinations are documented only in the conditional bodies; adding a state requires editing every operation; cross-operation invariants (e.g., 'only draft is editable') live as duplicated comparisons.
Each state is a class implementing the operation surface; the host delegates and transitions are state-to-state assignments. Adding a state is one new class; per-operation behaviour is locally visible inside each state.
Before the refactoring
// Every operation branches on the same state string; transitions are scattered.class Document {constructor() {this.state = 'draft';this.content = '';}edit(newContent) {if (this.state === 'draft') {this.content = newContent;} else if (this.state === 'in-review') {throw new Error('cannot edit while in review');} else if (this.state === 'published') {throw new Error('cannot edit a published document');}}submit() {if (this.state === 'draft') {this.state = 'in-review';} else if (this.state === 'in-review') {throw new Error('already in review');} else {throw new Error('cannot submit from ' + this.state);}}publish() {if (this.state === 'in-review') {this.state = 'published';} else {throw new Error('cannot publish from ' + this.state);}}}
After the refactoring
// Each state is a class; the document delegates and transitions polymorphically.class DraftState {edit(doc, newContent) { doc.content = newContent; }submit(doc) { doc.state = new InReviewState(); }publish(doc) { throw new Error('cannot publish from draft'); }}class InReviewState {edit() { throw new Error('cannot edit while in review'); }submit() { throw new Error('already in review'); }publish(doc) { doc.state = new PublishedState(); }}class PublishedState {edit() { throw new Error('cannot edit a published document'); }submit() { throw new Error('cannot resubmit'); }publish() { throw new Error('already published'); }}class Document {constructor() {this.state = new DraftState();this.content = '';}edit(newContent) { this.state.edit(this, newContent); }submit() { this.state.submit(this); }publish() { this.state.publish(this); }}
State-string-driven conditionals are Shotgun Surgery on the state field — a new state edits every operation; a renamed state edits every comparison. Bugs in one branch (forgot to throw, used wrong state name) ship as silent corruption of the state machine.
State pattern multiplies class count; the state machine's structure spreads across files. The transition graph is implicit (encoded in which state assigns to which other state); reading it requires traversing classes. Per-state objects allocate on every transition (in stateful instance form) unless flyweight-shared.
Each state's allowed operations are locally visible; illegal operations throw from the state itself, not from a per-method comparison. The transition graph is enforced by the state classes' assignments; new states ship without touching existing ones.
Many trivial states with mostly-throwing operations become a class hierarchy where the actual state machine is hard to scan. The pattern earns its keep when each state has meaningful behaviour; for state machines that are mostly 'this operation is illegal here', a state-driven dispatch table may stay clearer.