Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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 };
}

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 */ },
});

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';
  }
}

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 };
}

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 };
}

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.