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:
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:
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:
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:
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
}
}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:
export const testMachineConfig = {
initial: 'NOT_STARTED',
states: {
'NOT_STARTED': { on: { START: 'PROCESSING' } },
'PROCESSING': { on: { PASS: 'SUCCESS', FAIL: 'ERROR' } },
'SUCCESS': { type: 'final' },
'ERROR': { type: 'final' }
}
}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.
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 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:
// Cart service updates this
order.itemsAdded = true
// Payment service updates this
order.paymentReceived = true
// Fulfillment service updates this
order.shipped = trueNobody 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:
Current state: DRAFT
Reachable neighbors: [AWAITING_PAYMENT] (only one hop away)
Check AWAITING_PAYMENT validator
validators.AWAITING_PAYMENT = (o) => o.itemsAdded && !o.paymentReceived
// true && !true
// true && false
// false ✗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).
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
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.
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_PAYMENTMulti-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:
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:
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
XState Documentation — The go-to library for statecharts in JS
Statecharts.dev — Visual explainer of statechart concepts
Kent C. Dodds: Simple State Machine in JS — Minimal implementation walkthrough