blog-cover-imageblog-cover-image
Let's talk about State Machines!
engineering
#automata
#state_machines
2 / 2 /2026
9 / 2 /2026

Your order processing system just double-charged a customer. Your test pipeline shows "passed" but half the tests never ran. Your background job is stuck in "processing" forever.

You've debugged this before:

async function processOrder(order) {
    order.isProcessing = true
    
    await chargePayment(order)
    order.isPaid = true
    
    await sendToWarehouse(order)
    order.isShipped = true
    
    // Wait... what if payment fails but isProcessing is still true?
    // What if warehouse API times out?
    // Can order be isPaid AND isShipped AND isProcessing all at once?
}

The problem: Boolean flags everywhere. Race conditions. States that shouldn't exist but do.

The solution: State machines.


What is a State Machine!

A state machine is just: Given where I am + what happened → where do I go next?

Three Pieces:

  • States: Where you are (NOT_STARTED, PROCESSING, SUCCESS, ERROR)

  • Events: What happened (START, COMPLETE)

  • Transitions: The rules connecting them

Here's what that looks like:

State machine diagram

The key question: Who decides when those arrows fire?


Two Fundamentally Different Approaches

Here's what most articles won't tell you: There are two completely different ways to build state machines, and choosing the wrong one will hurt.

Side-by-Side Preview:

placeholder imageimage

Let me show you both with real code, explain when each breaks, and how to fix it.


Approach 1: Event-Driven (External Control)

The Pattern:

image

You explicitly send events to the machine, and it responds by transitioning between states. The machine is passive—it waits for you to tell it what happened.

Let's Build a Test Runner

For a test runner: given a test case, we need to run it, track whether it passes or fails, and determine the final state.

We'll build this in three layers:

Layer 1: The State Machine (The Rulebook)

This class doesn't track where you are! it just knows the rules of movement:

stateMachine.js
export class StateMachine {
    constructor(config) {
        this.config = config
    }

    getInitialState() {
        return this.config.initial
    }

    isFinal(state) {
        return this.config.states[state]?.type === 'final'
    }

    transition(currentState, event) {
        const stateConfig = this.config.states[currentState]
        if (!stateConfig) throw new Error(`Unknown state: ${currentState}`)
        
        // No transition for this event? Stay put.
        return stateConfig.on?.[event] ?? currentState
    }
}
The State Machine (Pure Transition Logic)

Key points:

  • Stateless: The machine doesn't track which test is in which state, it just knows the rules

  • Pure function: transition(state, event) → newState with no side effects

  • Defensive: If you send an invalid event (like CANCEL from NOT_STARTED), it just stays put

Layer 2: The Configuration (The Map)

This is the state config we pass to the state machine, a declarative definition of the diagram:

stateConfig.js
export const testMachineConfig = {
    initial: 'NOT_STARTED',
    states: {
        'NOT_STARTED': { on: { START: 'PROCESSING' } },
        'PROCESSING':  { on: { PASS: 'SUCCESS', FAIL: 'ERROR' } },
        'SUCCESS':     { type: 'final' },
        'ERROR':       { type: 'final' }
    }
}
The Configuration

Why separate config from logic?

  • Can validate it programmatically (detect unreachable states)

  • Can visualize it automatically (generate diagrams from config)

  • Can swap configs without changing code (different test types = different configs)

Layer 3: The TestRunner (The Orchestrator)

Now comes the crucial part that actually moves it from one state to another based on transitions.

runner.js
export class TestRunner {
    constructor(tests) {
        this.machine = new StateMachine(testMachineConfig)
        
        // Each test gets its own state, but shares the same machine logic
        this.tests = tests.map(test => ({
            ...test,
            state: this.machine.getInitialState()  // Start at NOT_STARTED
        }))

        // State → Action → Event
        // This is where side effects live (the actual work)
        this.stateActions = {
            [States.NOT_STARTED]: () => 'START',  // Immediately produce START event
            
            [States.PROCESSING]: async (test) => {
                // Run the actual test (async operation)
                return new Promise(resolve => {
                    test.run(passed => resolve(passed ? 'PASS' : 'FAIL'))
                })
            }
            // No action for SUCCESS/ERROR since they're final
        }
    }

    async runTest(test) {
        // Keep going until we hit a final state
        while (!this.machine.isFinal(test.state)) {
            const action = this.stateActions[test.state]
            
            if (action) {
                // 1. Execute the action for current state (e.g., run the test)
                const event = await action(test)
                
                // 2. Ask the machine: "I got this event, where do I go?"
                test.state = this.machine.transition(test.state, event)
                
                // 3. Loop continues with new state
            }
        }
        
        // When we exit: test.state is either SUCCESS or ERROR
        return test.state
    }
}
The Runner (Drives Events Into the Machine)

The Flow (Following the Pattern)

For a test that will pass:

1. State: NOT_STARTED

   - Execute Action: stateActions.NOT_STARTED() returns 'START'

   - Produce Event: 'START'

   - Transition: machine.transition('NOT_STARTED', 'START') → 'PROCESSING'

2. State: PROCESSING

   - Execute Action: stateActions.PROCESSING(test) runs the actual test

   - Produce Event: Test finishes → returns 'PASS'

   - Transition: machine.transition('PROCESSING', 'PASS') → 'SUCCESS'

3. State: SUCCESS

  - Check: machine.isFinal('SUCCESS') → true

   - Exit loop

The beauty: The state machine doesn't know about tests, and tests don't know about state machine logic. The runner is the glue that connects them.

Use when:

  • External events drive transitions (test results, user input, API responses)

  • You need to track state per instance (100 tests = 100 state values, 1 machine definition)

  • Actions have side effects (running tests, making API calls, writing to DB)

  • You want testability (mock the test.run() function, verify state transitions separately)


Approach 2: Validator-Driven (Data-Derived State)

No events. The machine inspects the data and computes what state it should be in.

The Idea:

In distributed systems, different services update different fields independently:

image
// Cart service updates this
order.itemsAdded = true

// Payment service updates this
order.paymentReceived = true

// Fulfillment service updates this
order.shipped = true

Nobody sends events, they mutate data. The state machine's job? Look at the data and figure out where we are.

This is the push model: Data changes, and the machine recomputes state as a pure function of that data.

First Attempt: Check Immediate Neighbors

The naive approach: from the current state, check which neighboring state's validator passes.

deriveState(model) {
    const reachable = transitions[model.state] || []
    
    for (const state of reachable) {
        if (validators[state]?.(model)) return state
    }
    return model.state  // No match, stay put
}

This works... until it doesn't.

The Problem: Multi-Field Updates

What if two services update simultaneously?

const order = {
    state: 'DRAFT',
    itemsAdded: true,      // Cart service ✓
    paymentReceived: true, // Payment service ✓ (both updated at once!)
    approved: false,
    shipped: false,
    delivered: false
}

machine.updateState(order)  // → Still DRAFT. Wait, what?

Why? Let's trace the naive algorithm:

  1. Current state: DRAFT

  2. Reachable neighbors: [AWAITING_PAYMENT] (only one hop away)

  3. Check AWAITING_PAYMENT validator

validators.AWAITING_PAYMENT = (o) => o.itemsAdded && !o.paymentReceived
                                       // true && !true
                                       // true && false
                                       // false ✗
  1. No match → return DRAFT

But the correct state is AWAITING_APPROVAL! We can't see it because it's two hops away, and we only checked immediate neighbors (one hop).

image

The Insight: We Need Graph Traversal

The state graph isn't a simple list, it's a directed graph. When data jumps ahead, we need to explore all reachable states to find where we actually belong.

That's BFS: level-by-level exploration of the graph.

From DRAFT, BFS visits:

Level 0: DRAFT              → validator fails (itemsAdded = true)
Level 1: AWAITING_PAYMENT   → validator fails (paymentReceived = true)  
Level 2: AWAITING_APPROVAL  → validator PASSES ✓
Level 3: READY_TO_SHIP      → validator fails
...

We want the furthest state whose validator passes, not the first. Why? If all fields are true, we should land on COMPLETED, not stop early at AWAITING_PAYMENT.

Building the Solution:

Step 1: Track visited states (graphs can have cycles)

const visited = new Set()

Step 2: Queue for BFS (FIFO = level-order traversal)

const queue = [model.state]

Step 3: Track the furthest valid state

let furthestValidState = model.state

Step 4: The BFS loop

while (queue.length > 0) {
    const currentState = queue.shift()  // FIFO: process oldest first
    
    if (visited.has(currentState)) continue
    visited.add(currentState)
    
    // Does this state's validator match our current data?
    if (validators[currentState]?.(model)) {
        furthestValidState = currentState  // Keep updating to furthest match
    }
    
    // Explore all neighbors (add to queue)
    for (const next of transitions[currentState] || []) {
        if (!visited.has(next)) queue.push(next)
    }
}

return furthestValidState

Key insight: We don't stop at the first match. We explore the entire reachable graph and remember the furthest state that validates.

The Complete Implementation

stateMachine.js
export class ValidatorDrivenStateMachine {
    constructor(config) {
        this.config = config
    }

    deriveState(model) {
        const { transitions, validators } = this.config
        
        const visited = new Set()
        const queue = [model.state]
        let furthestValidState = model.state

        while (queue.length > 0) {
            const currentState = queue.shift()

            if (visited.has(currentState)) continue
            visited.add(currentState)

            // Check if current data satisfies this state's requirements
            if (validators[currentState]?.(model)) {
                furthestValidState = currentState
            }

            // Enqueue all unvisited neighbors
            for (const next of transitions[currentState] || []) {
                if (!visited.has(next)) queue.push(next)
            }
        }

        return furthestValidState
    }

    updateState(model) {
        model.state = this.deriveState(model)
        return model.state
    }
}

Configuration:

Notice: No events, only validators that inspect data.

stateConfig.js
export const orderMachineConfig = {
    initial: 'DRAFT',

    // Graph structure: state → list of possible next states
    transitions: {
        'DRAFT':             ['AWAITING_PAYMENT'],
        'AWAITING_PAYMENT':  ['AWAITING_APPROVAL'],
        'AWAITING_APPROVAL': ['READY_TO_SHIP'],
        'READY_TO_SHIP':     ['SHIPPED'],
        'SHIPPED':           ['COMPLETED'],
        'COMPLETED':         []  // Final state, no exits
    },

    // Each state = a predicate over the data model
    validators: {
        'DRAFT':             (o) => !o.itemsAdded,
        'AWAITING_PAYMENT':  (o) => o.itemsAdded && !o.paymentReceived,
        'AWAITING_APPROVAL': (o) => o.paymentReceived && !o.approved,
        'READY_TO_SHIP':     (o) => o.approved && !o.shipped,
        'SHIPPED':           (o) => o.shipped && !o.delivered,
        'COMPLETED':         (o) => o.delivered
    }

Pattern in validators: Each state checks "this field is true AND next field is false"

This creates a natural ordering:

  • DRAFT: Nothing done yet

  • AWAITING_PAYMENT: Items added, but no payment

  • AWAITING_APPROVAL: Items + Payment, but not approved

  • And so on...

Now It Works:

Single-step update (normal case):

const order = { state: 'DRAFT', itemsAdded: false, ... }

order.itemsAdded = true
machine.updateState(order)  
// BFS: DRAFT fails → AWAITING_PAYMENT passes 
// Result: → AWAITING_PAYMENT

Multi-step update (the problem case):

const order = {
    state: 'DRAFT',
    itemsAdded: true,
    paymentReceived: true,
    approved: true,
    shipped: false,
    delivered: false
}

machine.updateState(order)  
// BFS explores entire graph:
// DRAFT fails (itemsAdded=true)
// AWAITING_PAYMENT fails (paymentReceived=true)  
// AWAITING_APPROVAL fails (approved=true)
// READY_TO_SHIP passes ✓ (approved && !shipped)
// SHIPPED fails (not shipped yet)
// Result: → READY_TO_SHIP (skipped 3 states!)
```

The machine catches up to where the data actually is.

The Pattern:

placeholder imageimage

Use When:

  • Multiple independent systems mutate the same model

    • Cart service, payment service, fulfillment service all touching the same order

  • State is a projection of boolean fields

    • "If items added AND payment received AND approved → must be READY_TO_SHIP"

  • You can't guarantee event ordering

    • Distributed systems, race conditions, offline-first apps

  • Idempotency matters

    • Calling updateState() multiple times with same data → same result


Event-Driven vs Validator-Driven

Event-Driven

- Trigger: Explicit events START, PASS)

- State: Stored and transitioned

- Control: You orchestrate transitions

- Debugging: Trace the event log

- Best for: UI, workflows, orchestration

Validator-Driven

- Trigger: Data mutations

- State: Computed on demand

- Control: Systems update fields independently

- Debugging: Inspect the data object

- Best for: Microservices, CRUD, async pipelines


When State Machines Grow: A Note on Statecharts

When your state machine grows beyond 5-6 states, or you find yourself creating states like LOADING_WITH_ERROR_RETRY_MODAL_OPEN, you've hit state explosion.

Statecharts solve this with nested and parallel states:

image

One parent state, multiple independent child concerns. Transitions can exit the entire parent regardless of which child you're in.

Libraries like XState make this practical. But that's a topic for another post, start flat, refactor to statecharts when the pain hits.


Resources