1 variableHolds the state; every change goes through send(event)
Show what to say
“For an object that behaves differently by phase, I write the state table first: every state and event pair has one outcome, and anything not listed is rejected with nothing changed. Then the object announces each legal change to subscribers, so listeners can be added without edits. I raise the Observer pitfalls before I am asked.”
Trade-offs
Table vs State objects: start with the table; move to State objects when a state grows its own fields or steps.
Push vs pull: push carries from, event and to, so listeners need no door; pull gives the newest state but loses changes in between.
Observer vs event bus: an observer attaches to one object; a bus routes topics, but you lose the direct call to follow.
Bring it up before they ask
A throwing listener must not stop the others, and a listener removed mid-notification still hears that one event, because emit loops over a copy.
0 of 0 sections ticked
30-second answer
Write the state table first: every state and event pair has one outcome, and anything unlisted is rejected with nothing changed. Then let the object announce each legal change, and guard against three Observer failures.
The flags that rot
A door can be open, closed or locked. The first version uses one flag per fact.
Two booleans describe four doors, but only three exist. Nothing stops open() then lock(): open and locked at once. A phase is one state: a single variable that holds only listed values.
// Two flags describe four doors. Only three of them exist, and nothing stops the fourth.export class Door { isOpen = false; isLocked = false; open() { if (!this.isLocked) this.isOpen = true; } close() { this.isOpen = false; } lock() { this.isLocked = true; } // forgot: an open door must not lock unlock() { this.isLocked = false; }}
Write the table first
List every state and event, and give each pair one outcome. The door has 3 states and 4 events:
State
Event
Next state
Action
closed
open
open
swing open
closed
lock
locked
throw bolt
open
close
closed
latch
locked
unlock
closed
retract bolt
That is 4 of the 12 pairs. The other 8 are rejected: nothing changes. The gaps (what does lock do on an open door?) are the questions to ask. The vending machine is the same exercise with more rows.
Two ways to code a table
As data: the table is a map and the machine looks it up.
As State objects: each state is a class that implements only the events it allows.
Both behave the same. The choice is where the per-state code lives.
Start with the table. Move to State objects when a state grows its own fields or steps.
// state -> event -> [next state, action]. A missing entry means "not allowed here".const TABLE = { closed: { open: ['open', 'swing open'], lock: ['locked', 'throw bolt'] }, open: { close: ['closed', 'latch'] }, locked: { unlock: ['closed', 'retract bolt'] },};export class Door { state = 'closed'; send(event) { // hasOwn: an event called 'toString' must not find Object.prototype.toString if (!Object.hasOwn(TABLE[this.state], event)) return { ok: false, state: this.state }; const [next, action] = TABLE[this.state][event]; this.state = next; return { ok: true, state: next, action }; }}
// Each state is an object. It answers only the events it allows; the base class says no to the rest.class State { open() { return null; } close() { return null; } lock() { return null; } unlock() { return null; }}class Closed extends State { name = 'closed'; open() { return [new Open(), 'swing open']; } lock() { return [new Locked(), 'throw bolt']; }}class Open extends State { name = 'open'; close() { return [new Closed(), 'latch']; }}class Locked extends State { name = 'locked'; unlock() { return [new Closed(), 'retract bolt']; }}const EVENTS = new Set(['open', 'close', 'lock', 'unlock']);export class Door { state = new Closed(); send(event) { const result = EVENTS.has(event) ? this.state[event]() : null; if (!result) return { ok: false, state: this.state.name }; const [next, action] = result; this.state = next; return { ok: true, state: next.name, action }; }}
Do the states differ only in which events they accept and where they lead?
Make illegal moves impossible
Shape the code to refuse bad states:
One variable holds the state. No flags to combine.
One door in: every change goes through send(event).
Decide before you mutate. A rejected event changes nothing.
Test every pair. Loop over all 12 and count the 4 that succeed.
Observer: subscribe, unsubscribe, notify
Think of a bell that everyone in the building hears. The ringer does not know who is listening. That is Observer.
A log wants every change, an alarm wants locks. If send calls each, Door grows with every listener. Instead the door keeps a list of callbacks and calls whoever signed up.
subscribereturns the way to unsubscribe. emit loops over a copy and catches each error. Why is next.
The observer pattern is a bell that rings once while everyone listening hears it, without the bell knowing who they are.
export class Emitter { #listeners = new Set(); // a Set: subscribing the same function twice is still one subscription subscribe(fn) { this.#listeners.add(fn); return () => this.#listeners.delete(fn); // hand back the way out, so callers cannot forget it } get size() { return this.#listeners.size; } emit(event) { const errors = []; for (const fn of [...this.#listeners]) { // loop over a copy: listeners may unsubscribe while we run try { fn(event); } catch (err) { errors.push(err); } // one bad listener must not silence the rest } return errors; }}
Let the door announce itself
Combine the table door with the emitter. The door changes state first, then announces, and only for legal moves.
The event is pushed: it carries from, event and to, so a listener needs no reference to the door. Pull only says "something changed" and loses changes that pile up. Push is the safer default.
Step through: the door comes first, and each stage adds classes without editing earlier ones.
import { Door } from './door-table.js';import { Emitter } from './emitter.js';export class ObservableDoor { #door = new Door(); #changes = new Emitter(); get state() { return this.#door.state; } subscribe(listener) { return this.#changes.subscribe(listener); } send(event) { const from = this.#door.state; const result = this.#door.send(event); // Announce after the change is done, and only for legal moves. Push: the event says what happened. const errors = result.ok ? this.#changes.emit({ from, event, to: result.state }) : []; return { ...result, errors }; }}
1 / 4
1
Stage 1 of 4
One class
Door owns its state and a send(event) method. If a log, an alarm and a display should react to changes, the only place to write them is inside send, and Door grows with every new reaction.
Door owns its state and the transition table behind send(event). An Emitter inside it holds a set of listeners and calls each one after a legal transition. Listeners implement one tiny interface, so the log, the alarm and a display can be added without editing Door.
◆ composition (owns)
◇ aggregation
△ inheritance / implements (dashed)
→ association · ⇢ dependency
Where Observer bites
Play the sequence, then switch branches: one listener throws, or one unsubscribes itself mid-loop.
Unsubscribing mid-notification. Removing while looping skips entries, so loop over a copy. A listener removed mid-way still hears that one event.
A throwing listener. One bad listener must not stop the rest. Catch per listener.
Memory leaks. A short-lived listener on a long-lived door leaks unless it calls the function subscribe returned.
0 / 6
Press Play to watch it run · 6 steps
The client sends lock. The door updates its state first, then asks the emitter to notify. Pick a branch to see every listener succeed, one listener throw, or a listener unsubscribe itself in the middle of the notification.
Locking the door, step by stepThe client sends lock. The door updates its state first, then asks the emitter to notify. Pick a branch to see every listener succeed, one listener throw, or a listener unsubscribe itself in the middle of the notification.send('lock')Table lookup: closed + lock is legal. State becomes locked before anyone is told.emit(closed, lock, locked)The emitter copies its listener set firstalt [all listeners fine]listener(event)listener(event)errors: noneok, state locked
Client
Service
Pub/Sub
Worker
Text description
Client → Door: send('lock')
Door → Emitter: emit(closed, lock, locked)
Emitter → DoorLog: listener(event)
Emitter → Alarm: listener(event)
Emitter replies to Door: errors: none
Door replies to Client: ok, state locked
Observer or an event bus
An observer is attached to one object: you subscribe to this door. An event bus sits in the middle: publishers announce a topic such as door.locked, subscribers ask for topics, and neither knows the other.
Observer
Event bus
You subscribe to
One object
A topic or pattern
Typical reach
One process
Many objects, maybe many processes
Use Observer for a few listeners on one object. Use a bus when many parts need the same events, and accept no direct call to follow. The event bus exercise builds one.
Try it
Add lock to the open row, then add a state of your own. The log changes with no edit to the emitter.
// The table is data. Add an entry (try lock under open) or a whole state, and the machine changes with it.const table = { closed: { open: 'open', lock: 'locked' }, open: { close: 'closed' }, locked: { unlock: 'closed' },};let state = 'closed';const changes = new Emitter();changes.subscribe(({ from, event, to }) => console.log(`${from} --${event}--> ${to}`));changes.subscribe(({ to }) => { if (to === 'locked') console.log(' alarm armed'); });function send(event) { const to = Object.hasOwn(table[state], event) ? table[state][event] : null; if (!to) return console.log(`${state}: ${event} ignored`); const from = state; state = to; // change first, announce second changes.emit({ from, event, to });}['open', 'lock', 'close', 'lock', 'open', 'unlock'].forEach(send);
Follow-up questions
How is State different from Strategy?
A strategy is picked from outside. A state replaces itself as events arrive. The shapes match, so the difference is who chooses.
What if a listener must be able to veto a change?
That is not Observer, which reports facts after the fact. Use a guard in the table.
Check yourself
Question 1 of 3
Score 2 out of 3 or better to mark this page complete.
Code it yourself
Write the class in the browser editor, in JavaScript or Python. Each exercise runs against hidden tests, and the step-by-step solution unlocks after a wrong submission.