Part II: "We Already Solved This 28 Times"
The site's frontend has 28 state machines in src/lib/. Each one follows the same pattern. Each one is under 100 lines. Each one is independently testable without a DOM, without a browser, without any I/O at all.
We'd been using this pattern for months — for SPA navigation, scroll spy, copy feedback, sidebar resizing, theme switching, keyboard navigation, zoom/pan, font sizing. Every piece of interactive behavior was a state machine.
And the developer CLI? Booleans and if chains.
The Pattern
Here's createCopyFeedback() — one of the simplest machines in the frontend:
export type CopyFeedbackState = 'idle' | 'copying' | 'success' | 'error';
export interface CopyFeedbackOptions {
resetDelay: number;
onStateChange?: (state: CopyFeedbackState, prev: CopyFeedbackState) => void;
scheduleReset?: (delayMs: number, callback: () => void) => void;
cancelReset?: () => void;
}
export function createCopyFeedback(options: CopyFeedbackOptions): CopyFeedbackMachine {
let state: CopyFeedbackState = 'idle';
function transition(next: CopyFeedbackState): boolean {
if (!canTransition(state, next)) return false;
const prev = state;
state = next;
onStateChange(next, prev);
return true;
}
function copy(): void {
if (state === 'success' || state === 'error') {
cancelReset();
state = 'idle';
}
transition('copying');
}
function succeed(): void {
if (transition('success')) scheduleAutoReset();
}
return { copy, succeed, fail, reset, getState: () => state };
}export type CopyFeedbackState = 'idle' | 'copying' | 'success' | 'error';
export interface CopyFeedbackOptions {
resetDelay: number;
onStateChange?: (state: CopyFeedbackState, prev: CopyFeedbackState) => void;
scheduleReset?: (delayMs: number, callback: () => void) => void;
cancelReset?: () => void;
}
export function createCopyFeedback(options: CopyFeedbackOptions): CopyFeedbackMachine {
let state: CopyFeedbackState = 'idle';
function transition(next: CopyFeedbackState): boolean {
if (!canTransition(state, next)) return false;
const prev = state;
state = next;
onStateChange(next, prev);
return true;
}
function copy(): void {
if (state === 'success' || state === 'error') {
cancelReset();
state = 'idle';
}
transition('copying');
}
function succeed(): void {
if (transition('success')) scheduleAutoReset();
}
return { copy, succeed, fail, reset, getState: () => state };
}Four elements, always the same:
1. Factory Function
Not a class. A function that returns an interface. Closure captures state. No this, no new, no inheritance. Just a function that creates a machine and returns its public API.
2. Callback Injection
The machine doesn't know about setTimeout. It receives a scheduleReset callback. The machine doesn't know about the DOM. It calls onStateChange and lets the caller decide what to do. Side effects are injected, not hardcoded.
In tests, inject a mock:
const machine = createCopyFeedback({
resetDelay: 1000,
scheduleReset: (ms, cb) => { /* capture, don't schedule */ },
});const machine = createCopyFeedback({
resetDelay: 1000,
scheduleReset: (ms, cb) => { /* capture, don't schedule */ },
});3. Guard Clauses
Events that don't apply in the current state are silently ignored:
function canTransition(from: CopyFeedbackState, to: CopyFeedbackState): boolean {
switch (from) {
case 'idle': return to === 'copying';
case 'copying': return to === 'success' || to === 'error';
case 'success': return to === 'idle' || to === 'copying';
case 'error': return to === 'idle' || to === 'copying';
}
}function canTransition(from: CopyFeedbackState, to: CopyFeedbackState): boolean {
switch (from) {
case 'idle': return to === 'copying';
case 'copying': return to === 'success' || to === 'error';
case 'success': return to === 'idle' || to === 'copying';
case 'error': return to === 'idle' || to === 'copying';
}
}You can call succeed() when idle. Nothing happens. No crash, no error, no log. The event is simply not valid in that state, so it's discarded. This is intentional — it makes machines safe to compose. Fire events freely; the machine knows what to accept.
4. Closure State
state is a let inside the factory closure. It's not on this. It's not in a Map. It's not in a global. It can only be read via getState() and changed via the transition function. No external mutation possible.
The Lightbulb
The old workflow.ts had exactly the same structure — it just wasn't formalized:
| Frontend pattern | CLI equivalent |
|---|---|
CopyFeedbackState = 'idle' | 'copying' | 'success' |
waitingForBuildMenu: boolean |
transition(next) |
waitingForBuildMenu = false; showMenu(); |
Guard clause: if (!canTransition(...)) return |
if (waitingForBuildMenu) { handleBuildMenu(); return; } |
onStateChange callback |
console.log('\x1b[36m...\x1b[0m') hardcoded |
The old code was a state machine. It just didn't know it.
Why Not xstate?
xstate is excellent. It's also 50KB minified, uses interpreters and config objects, and introduces concepts (services, actors, spawn, invoke) that are overkill for what we need.
Our pattern is ~80 lines per machine. No runtime dependency. No config DSL. Just TypeScript functions. The type checker enforces the state transitions. The factory function is the entire API.
When you can express your machine in 80 lines of plain TypeScript, a framework adds complexity without adding capability.
Side-by-Side: Frontend vs CLI
Here's the shape comparison — createSpaNavMachine() from the frontend next to createRunnerMachine() for the CLI:
Frontend (SPA navigation):
function createSpaNavMachine(callbacks: SpaNavCallbacks) {
let state: SpaNavState = 'idle';
function navigate(targetPath, currentPath, hash, href) {
if (state !== 'idle' && state !== 'settled') return; // guard
transition('fetching');
callbacks.startFetch(targetPath); // callback
}
function fetchComplete(html) {
if (state !== 'fetching') return; // guard
callbacks.swapContent(html); // callback
transition('settled');
}
return { navigate, fetchComplete, abort, getState: () => state };
}function createSpaNavMachine(callbacks: SpaNavCallbacks) {
let state: SpaNavState = 'idle';
function navigate(targetPath, currentPath, hash, href) {
if (state !== 'idle' && state !== 'settled') return; // guard
transition('fetching');
callbacks.startFetch(targetPath); // callback
}
function fetchComplete(html) {
if (state !== 'fetching') return; // guard
callbacks.swapContent(html); // callback
transition('settled');
}
return { navigate, fetchComplete, abort, getState: () => state };
}CLI (command execution):
function createRunnerMachine(callbacks: RunnerCallbacks) {
let state: RunnerState = 'idle';
function start(cmd, label) {
if (state !== 'idle') return; // guard
transition('running');
callbacks.spawn(cmd); // callback
}
function exit(code) {
if (state !== 'running') return; // guard
transition(code === 0 ? 'completed' : 'failed');
callbacks.showResult(code === 0, duration); // callback
}
return { start, exit, stdout, tick, abort, getState: () => state };
}function createRunnerMachine(callbacks: RunnerCallbacks) {
let state: RunnerState = 'idle';
function start(cmd, label) {
if (state !== 'idle') return; // guard
transition('running');
callbacks.spawn(cmd); // callback
}
function exit(code) {
if (state !== 'running') return; // guard
transition(code === 0 ? 'completed' : 'failed');
callbacks.showResult(code === 0, duration); // callback
}
return { start, exit, stdout, tick, abort, getState: () => state };
}Same shape. Same four elements. Different domain.
The decision was simple: use the exact same pattern. No library. No framework. Just the same TypeScript idiom, applied to a different problem.
Next: Part III: Four Machines Replace Six Globals — The actual machines we built, with state diagrams and before/after diffs.