Replace State-Altering Conditionals with State

Destination
Symptom

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.

Goal

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); }
}
Example source: Illustrative example written for this site, faithful to Kerievsky's pattern shape in Refactoring to Patterns (Addison-Wesley, 2004), chapter 7. Per ADR-0004, the bookend contrasts the state-string dispatcher with polymorphic state transition — the intermediate Extract Class + Move Function steps live in the linked Fowler refactorings. The book uses a media-player state-machine; this JavaScript version uses a document publishing flow.
Pressure

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.

Tradeoff

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.

Relief

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.

Trap

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.