State & Other Patterns
Beyond the headline patterns, a handful of smaller behavioral and structural patterns quietly solve recurring problems in everyday JavaScript. The state, command, and adapter patterns each tame a specific kind of complexity: tangled conditionals, scattered action logic, and mismatched interfaces. This page gives you a fast, practical tour of all three with runnable examples and clear guidance on when to reach for each.
State pattern
The state pattern lets an object change its behavior when its internal state changes, so it appears to switch classes at runtime. It is the object-oriented cure for sprawling if/switch blocks that branch on a status string. Instead of asking “what state am I in?” everywhere, each state becomes its own object that knows how to handle events and which state comes next.
Think of a media player. The play action means different things depending on whether the player is stopped, playing, or paused. A finite state machine captures the legal transitions explicitly.
const transitions = {
stopped: { play: "playing" },
playing: { pause: "paused", stop: "stopped" },
paused: { play: "playing", stop: "stopped" },
};
class Player {
constructor() {
this.state = "stopped";
}
dispatch(action) {
const next = transitions[this.state][action];
if (!next) {
console.log(`Ignored "${action}" while ${this.state}`);
return;
}
console.log(`${this.state} --${action}--> ${next}`);
this.state = next;
}
}
const p = new Player();
p.dispatch("play");
p.dispatch("pause");
p.dispatch("stop");
p.dispatch("pause"); // illegal from stopped
Output:
stopped --play--> playing
playing --pause--> paused
paused --stop--> stopped
Ignored "pause" while stopped
The transition table is data, not branching logic, which makes illegal transitions impossible to trigger by accident. For richer behavior, swap the string states for objects that carry their own methods.
Tip: A declarative transition map like the one above is the easiest finite state machine to test — you can assert on the table directly without simulating events. Libraries such as XState formalize this idea with guards, side effects, and nested states.
Command pattern
The command pattern wraps a request as a standalone object, decoupling the code that triggers an action from the code that performs it. Because each command is a first-class value, you can queue them, log them, pass them around, and — crucially — implement undo by pairing every execute with an undo.
class History {
constructor() {
this.done = [];
}
run(command) {
command.execute();
this.done.push(command);
}
undo() {
const command = this.done.pop();
if (command) command.undo();
}
}
const makeAddText = (doc, text) => ({
execute: () => { doc.content += text; },
undo: () => { doc.content = doc.content.slice(0, -text.length); },
});
const doc = { content: "" };
const history = new History();
history.run(makeAddText(doc, "Hello"));
history.run(makeAddText(doc, ", world"));
console.log(doc.content);
history.undo();
console.log(doc.content);
Output:
Hello, world
Hello
Each command is a small object with a uniform interface, so the History invoker never needs to know what any individual command actually does. This is exactly how editors implement undo/redo stacks and how task queues serialize work.
Adapter and facade patterns
An adapter translates one interface into another so two incompatible pieces of code can collaborate. A facade is closely related: it hides a messy or sprawling subsystem behind a single, friendly entry point. Both are structural patterns about presenting a cleaner surface.
Suppose a third-party logger exposes writeLine, but your app expects a standard log method:
// Incompatible third-party API
const vendorLogger = {
writeLine(level, message) {
console.log(`[${level.toUpperCase()}] ${message}`);
},
};
// Adapter: presents the interface our app expects
const loggerAdapter = {
info: (msg) => vendorLogger.writeLine("info", msg),
warn: (msg) => vendorLogger.writeLine("warn", msg),
error: (msg) => vendorLogger.writeLine("error", msg),
};
loggerAdapter.info("Service started");
loggerAdapter.error("Disk almost full");
Output:
[INFO] Service started
[ERROR] Disk almost full
A facade goes a step further by orchestrating several calls behind one method. For example, a checkout(cart) facade might validate stock, charge payment, and send a receipt — callers never touch those subsystems directly. The adapter changes shape; the facade reduces surface area.
Choosing the right pattern
| Pattern | Type | Problem it solves | Reach for it when |
|---|---|---|---|
| State | Behavioral | Behavior that depends on a changing internal mode | Status-driven if/switch chains keep growing |
| Command | Behavioral | Coupling between trigger and action | You need undo/redo, queuing, or logging of actions |
| Adapter | Structural | Two incompatible interfaces | Integrating a third-party or legacy API |
| Facade | Structural | A subsystem that is hard to use directly | You want one simple entry point over many calls |
Best practices
- Reach for the state pattern only once branching on a status grows past two or three values — a single
ifdoes not need a state machine. - Keep state transitions declarative (a table or map) so invalid transitions are unrepresentable and easy to unit test.
- Give every command a matching
undo, and store executed commands in a stack to get redo nearly for free. - Keep commands free of UI concerns so the same command works from a button, a keyboard shortcut, or a script.
- Use an adapter to quarantine third-party APIs behind your own interface — swapping vendors later then touches one file.
- Prefer a facade over leaking subsystem details to callers, but do not let it become a god object that does everything.
- Don’t apply a pattern preemptively; introduce it when duplication or coupling actually appears.