State Machines & Patterns
When Simple State Isn't Enough
This appendix picks up the advanced state management patterns from Chapter 7. Once your programs outgrow a handful of simple variables, these are the patterns that keep complexity from quietly getting the better of you.
State Machines: When State Has Structure
So far, we’ve treated state as free-form memory: variables that can become anything at any time, no questions asked. But sometimes state has better manners than that. Sometimes there are rules about which state is allowed to follow which, and which events are allowed to trigger the change.
State machines handle this. You interact with state machines every day.
Traffic Lights: A State Machine in the Wild
Take a traffic light. At any given moment, it’s in exactly one state: red, yellow, or green. It can’t be “sort of red” or “between yellow and green,” the way a kettle can sit lukewarm on the counter. One state, clearly defined, no hedging.
More importantly, the transitions follow strict rules. A traffic light doesn’t wander randomly from red to yellow to green to red to green like it’s improvising jazz. The sequence is fixed: green to yellow, yellow to red, red to green, and back around again, forever.
Vending Machines: State With Purpose
A vending machine is the next step up, a little fancier because it has to remember how close you are to actually getting your snack.
A vending machine goes through these states:
- Waiting (no money inserted)
- Has partial payment (some money, but not enough)
- Has sufficient payment (enough to buy something)
- Dispensing (giving you the snack you selected)
- Returning change (if applicable)
Each state allows a different set of actions. In “Waiting,” you can feed it money or jab at the buttons (though jabbing the buttons does precisely nothing yet). In “Has sufficient payment,” pressing a button finally accomplishes something.
Real vending machines are more complicated than this, with states for “out of stock,” “returning your money because the machine is broken,” and “keeping your money because the machine is broken and harbors personal grudges.” But the principle holds: clear states, clear transitions, no ambiguity about where you are in the process.
The Perils of Shared Mutable State
One of programming’s great wellsprings of confusion, bugs, and 2 a.m. debugging sessions is shared mutable state. Three words, and each one earns its place.
“State” you know: it’s what the program remembers. “Mutable” means it can change. “Shared” means multiple parts of the program can access and modify it. Put them together, and you have three cooks reaching into the same pot, each certain they’re the only one stirring.
The Shared Notebook Problem
Imagine you and a friend are collaborating on a story. One notebook is shared between you. The rule is simple: anyone can write in the notebook at any time.
You write: “The dragon entered the village.”
While you are getting a coffee, your friend writes: “The village was destroyed by a meteor before the dragon arrived.”
You return and continue: “The villagers greeted the dragon warmly.”
Wait. The village was destroyed. There are no villagers. Your sentence makes no sense now. Both of you were writing in the same notebook, but you each had different ideas about what state the story was in.
Solutions: Reducing the Sharing
The simplest solution is to share less. Instead of a global score everyone modifies:
function calculateNewScore(currentScore, points) {
return currentScore + points;
}
let score = 0;
score = calculateNewScore(score, 10); // Award points
score = calculateNewScore(score, -5); // Penalty
Now calculateNewScore touches no shared state. It takes a value, returns a new value, and leaves the world otherwise unchanged.
The shared notebook problem scales up terrifyingly. Imagine not two writers but two hundred, all reaching for the same pen at once, not one notebook but a million interconnected variables. This is why so much of advanced programming focuses on techniques for managing shared state: locks, transactions, immutable data structures, message passing.
Event Sourcing: Recording History Instead of Current State
There’s a completely different way to think about state, and once it clicks you’ll start seeing it everywhere.
The Ledger vs. The Balance
Take a bank account. You could represent it with a single number: the current balance. That’s the “current state” approach. If the balance is $1,247.82, that’s the state. When you buy a $4.50 coffee, the state updates: now it’s $1,243.32.
But banks don’t actually work that way, and for good reason. Banks keep a ledger: a record of every transaction. Deposit $500 on Monday. Withdraw $20 on Tuesday. Deposit $100 on Wednesday. The balance is derived from the ledger by adding up everything that ever happened. Which means the balance, that single confident number, was never really the fundamental thing. It’s a summary, a story the ledger tells about itself.
This seems like more work, but look at what it gives you:
- History: You can see exactly how the current balance was reached
- Auditability: You can verify that the balance is correct by recalculating
- Time travel: You can ask “what was my balance on Tuesday?”
- Reconstruction: If something goes wrong, you can rebuild the current state from the events
Why This Matters
Event sourcing solves several problems at once:
The debugging problem: When the score is wrong, you can examine the event history and see exactly what happened.
The undo problem: Want to undo the last action? Remove the last event and recalculate.
The time-travel problem: Want to know what the score was after three events? Calculate using only the first three events.
The synchronization problem: If you only ever append to the event list (never modifying existing events) and calculate state from events, a whole class of problems gets shown the door, the way a tidy queue prevents the scrum at an unmarked counter.
Event sourcing is particularly popular in financial systems (where audit trails are legally required), collaborative applications (where you need to know who changed what when), and games (where replays need to reconstruct exactly what happened). It’s also why Git works the way it does. Your repository is really just a ledger of changes, and any version of your code can be reconstructed from that history.
A Tiny Text Adventure
A simple exploration game that demonstrates all these concepts:
const readline = require("node:readline/promises");
async function main() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
let location = "entrance";
while (true) {
if (location === "entrance") {
console.log("\nYou are at the entrance of a cave.");
console.log("You can go: north");
} else if (location === "cave") {
console.log("\nYou are inside a dark cave.");
console.log("You can go: south");
console.log("There is a treasure here!");
}
const command = await rl.question("\nWhat do you do? ");
if (command === "north" && location === "entrance") {
location = "cave";
console.log("You venture into the darkness...");
} else if (command === "south" && location === "cave") {
location = "entrance";
console.log("You return to the entrance.");
} else if (command === "quit") {
console.log("Thanks for playing!");
break;
} else {
console.log("You can't do that here.");
}
}
rl.close();
}
main();
This program has state (the location variable), it accepts input (your commands), it produces output (descriptions of where you are), and it runs continuously until you quit. It is a conversation. You tell it what to do, it tells you what happens, and you respond to that. Back and forth.
If you’re thinking, “Wait, that means World of Warcraft is fundamentally just a really complicated version of this two-room cave,” you’re absolutely right. More state (millions of players, countless locations), faster loops (60 frames per second instead of waiting for input), fancier output (3D graphics instead of text). But the pattern is identical. Input, state, output, loop. Everything else is polish.