Modeling State Machines as Data

State machines have a reputation for being a dense topic, but they become a lot more approachable when you build a minimal implementation yourself. The key insight is that a state machine can be expressed entirely as a plain JavaScript object: the current state is just a string, and transitions are just a lookup table.

A useful starting point is a simple toggle. Its machine definition contains an id, an initialState, and a states object. Each state (like off or on) is a key in that object:

function createMachine(stateMachineDefinition) {
	const machine = {
		// machine object
	}
	return machine
}

// here's how we'll create the state machine
const machine = createMachine({
	// state machine definition object here...
})

// here's how we use the state machine
// comments are what we _want_ to have logged
let state = machine.value
console.log(`current state: ${state}`) // current state: off

state = machine.transition(state, 'switch')
console.log(`current state: ${state}`) // current state: on

state = machine.transition(state, 'switch')
console.log(`current state: ${state}`) // current state: off

The definition object is the contract our code will work with. We need to model three things from the core state machine requirements:

  • An initial state that the machine enters automatically when it starts.
  • Per-state actions (side effects) triggered on entering or exiting a state.
  • Events that trigger transitions between states, potentially with their own actions.

Building this object incrementally is helpful. For the initial state handling, the user simply provides a string: initialState: 'off'. Each state definition can then have its own onEnter and onExit hook functions. For a concrete example, you can pass context to these actions, defining them in a factory function so they have a reference to a label for logging:

const machine = createMachine({
	initialState: 'off',
	off: {
		actions: {
			onEnter() {},
			onExit() {},
		},
	},
	on: {
		actions: {
			onEnter() {},
			onExit() {},
		},
	},
})

Defining them inline with state-specific closures:

const machine = createMachine({
	initialState: 'off',
	off: {
		actions: {
			onEnter() {
				console.log('off: onEnter')
			},
			onExit() {
				console.log('off: onExit')
			},
		},
	},
	on: {
		actions: {
			onEnter() {
				console.log('on: onEnter')
			},
			onExit() {
				console.log('on: onExit')
			},
		},
	},
})

Next, each state needs a transitions property. The structure maps an event name (like 'switch') to a transition object that specifies the target state. This is how the machine knows to react to an event by exiting one state and entering another:

const machine = createMachine({
	initialState: 'off',
	off: {
		actions: {
			onEnter() {
				console.log('off: onEnter')
			},
			onExit() {
				console.log('off: onExit')
			},
		},
		transitions: {
			switch: {
				target: 'on',
				action() {
					console.log('transition action for "switch" in "off" state')
				},
			},
		},
	},
	on: {
		actions: {
			onEnter() {
				console.log('on: onEnter')
			},
			onExit() {
				console.log('on: onExit')
			},
		},
		transitions: {
			switch: {
				target: 'off',
				action() {
					console.log('transition action for "switch" in "on" state')
				},
			},
		},
	},
})

Defining both sides of the toggle requires the off and on states to listen for the same 'switch' event, targeting each other's state:

const machine = createMachine({
	initialState: 'off',
	off: {
		actions: {
			onEnter() {
				console.log('off: onEnter')
			},
			onExit() {
				console.log('off: onExit')
			},
		},
		transitions: {
			switch: {},
		},
	},
	on: {
		actions: {
			onEnter() {
				console.log('on: onEnter')
			},
			onExit() {
				console.log('on: onExit')
			},
		},
		transitions: {
			switch: {},
		},
	},
})

Implementing the Transition Logic

With the definition object in place, we can create a createMachine factory. The machine's public API exposes a value property for the current state, and a transition method. Rather than hiding the state internally, we can follow a functional pattern where transition accepts the current state as an argument.

The core implementation boils down to a few discrete checks and steps. Given a currentState and an event, the factory must look up the relevant transition from the state's definitions:

function createMachine(stateMachineDefinition) {
	const machine = {
		value: stateMachineDefinition.initialState,
		transition(currentState, event) {
			const currentStateDefinition = stateMachineDefinition[currentState]
			const destinationTransition = currentStateDefinition.transitions[event]

			return machine.value
		},
	}
	return machine
}

When an event is not defined for the current state, the transition shouldn't happen; the factory should just return the machine without changes. If a matching transition is found, the process is straightforward:

  1. Grab the transition's action.
  2. Trigger the current state's onExit.
  3. Trigger the transition's own action.
  4. Enter the target state and call its onEnter.
  5. Update the machine value to the target state.

The critical sequence is implemented by resolving the destination state definition and executing the hooks in the correct order:

function createMachine(stateMachineDefinition) {
	const machine = {
		value: stateMachineDefinition.initialState,
		transition(currentState, event) {
			const currentStateDefinition = stateMachineDefinition[currentState]
			const destinationTransition = currentStateDefinition.transitions[event]
			if (!destinationTransition) {
				return
			}
			const destinationState = destinationTransition.target
			const destinationStateDefinition =
				stateMachineDefinition[destinationState]

			destinationTransition.action()
			currentStateDefinition.actions.onExit()
			destinationStateDefinition.actions.onEnter()

			return machine.value
		},
	}
	return machine
}

After the hooks run, the machine updates its value, making it ready to handle the next event immediately:

function createMachine(stateMachineDefinition) {
	const machine = {
		value: stateMachineDefinition.initialState,
		transition(currentState, event) {
			const currentStateDefinition = stateMachineDefinition[currentState]
			const destinationTransition = currentStateDefinition.transitions[event]
			if (!destinationTransition) {
				return
			}
			const destinationState = destinationTransition.target
			const destinationStateDefinition =
				stateMachineDefinition[destinationState]

			destinationTransition.action()
			currentStateDefinition.actions.onExit()
			destinationStateDefinition.actions.onEnter()

			machine.value = destinationState

			return machine.value
		},
	}
	return machine
}

Examining the Full Implementation

Put together, the state machine factory links the definition object to this event-handling logic. The transition function returns the machine itself, so it can be chained for testing sequences of events, observing output as it goes.

Testing this in a browser console with the toggle machine definition yields a set of logs that map perfectly to the transition flow:

  • 'off' state logs an exit
  • The 'switch' transition log appears
  • The target 'on' state logs its entry

The same log sequence repeats in reverse when toggling back. This small example captures the essence of the state machine pattern: a clear, declarative definition of state, actions, and state transitions, which is valuable for complex roles like handling UI flows or network requests. The code is intentionally simple and should not be used in production; for that, look at a mature library like xstate.

For those interested in going deeper, the statecharts.github.io website, maintained by Erik Mogensen, is an excellent resource. It clearly explains the fundamentals, including what a state machine is and how statecharts extend beyond basic state machines.