As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
JavaScript state machines provide a powerful pattern for managing complex application states and transitions. I've implemented numerous state machines across projects and found them invaluable for controlling UI flows, form validations, and application logic. Let me share six effective approaches to implementing finite state machines in JavaScript.
State machines fundamentally consist of states, events, and transitions. They're particularly effective when your application has distinct operational modes with clear rules for moving between them.
State Definition with Plain Objects
The simplest way to implement a state machine is using plain JavaScript objects. This approach is lightweight and requires no external dependencies.
const lightSwitchMachine = {
initialState: 'off',
states: {
off: {
transitions: {
TOGGLE: 'on'
},
onEntry: () => console.log('Light turned off')
},
on: {
transitions: {
TOGGLE: 'off'
},
onEntry: () => console.log('Light turned on')
}
}
};
I've found this approach works well for simpler state machines. The definition is clear and easy to understand. Each state contains its possible transitions and optional event handlers.
For implementation, we need a basic state machine manager:
function createMachine(stateMachineDefinition) {
let currentState = stateMachineDefinition.initialState;
return {
getCurrentState: () => currentState,
transition(event, payload) {
const currentStateDefinition = stateMachineDefinition.states[currentState];
const nextState = currentStateDefinition.transitions[event];
if (!nextState) {
return;
}
const prevState = currentState;
currentState = nextState;
if (currentStateDefinition.onExit) {
currentStateDefinition.onExit(prevState, nextState, payload);
}
if (stateMachineDefinition.states[nextState].onEntry) {
stateMachineDefinition.states[nextState].onEntry(prevState, nextState, payload);
}
return currentState;
}
};
}
const lightSwitch = createMachine(lightSwitchMachine);
console.log(lightSwitch.getCurrentState()); // "off"
lightSwitch.transition('TOGGLE'); // "on"
Event Handlers for Transitions
Proper event handling is crucial for state machines. In complex applications, transitions often need validation or trigger side effects.
class StateMachine {
constructor(config) {
this.states = config.states;
this.currentState = config.initialState;
this.transitionHooks = [];
}
addTransitionHook(hook) {
this.transitionHooks.push(hook);
return () => {
this.transitionHooks = this.transitionHooks.filter(h => h !== hook);
};
}
dispatch(event, payload = {}) {
const currentStateConfig = this.states[this.currentState];
const transition = currentStateConfig.transitions[event];
if (!transition) {
console.warn(`No transition found for event "${event}" in state "${this.currentState}"`);
return false;
}
// Determine next state
const nextState = typeof transition === 'function'
? transition(payload, this.currentState)
: transition;
if (!nextState || nextState === this.currentState) {
return false;
}
const prevState = this.currentState;
// Run all transition hooks
for (const hook of this.transitionHooks) {
hook(prevState, nextState, event, payload);
}
// Exit current state
if (currentStateConfig.onExit) {
currentStateConfig.onExit(payload, event);
}
// Update state
this.currentState = nextState;
// Enter new state
if (this.states[nextState].onEntry) {
this.states[nextState].onEntry(payload, event);
}
return true;
}
}
With this implementation, I can define transitions as either direct mappings to new states or functions that determine the next state based on the event payload.
const formMachine = new StateMachine({
initialState: 'idle',
states: {
idle: {
transitions: {
SUBMIT: (payload) => payload.isValid ? 'submitting' : 'error'
}
},
submitting: {
onEntry: () => startSubmitAnimation(),
transitions: {
SUCCESS: 'success',
FAILURE: 'error'
},
onExit: () => stopSubmitAnimation()
},
error: {
transitions: {
RETRY: 'idle'
}
},
success: {
transitions: {
RESET: 'idle'
}
}
}
});
formMachine.dispatch('SUBMIT', { isValid: false }); // Transitions to "error"
State History Tracking
Adding history to state machines enables powerful features like undo/redo functionality and debugging during development.
class HistoryStateMachine extends StateMachine {
constructor(config) {
super(config);
this.history = [this.currentState];
this.future = [];
this.maxHistory = config.maxHistory || 100;
}
dispatch(event, payload = {}) {
const success = super.dispatch(event, payload);
if (success) {
this.future = []; // Clear future when new action occurs
this.history.push(this.currentState);
// Limit history size
if (this.history.length > this.maxHistory) {
this.history.shift();
}
}
return success;
}
undo() {
if (this.history.length <= 1) return false;
this.future.unshift(this.history.pop());
this.currentState = this.history[this.history.length - 1];
// Call onEntry for restored state
if (this.states[this.currentState].onEntry) {
this.states[this.currentState].onEntry({}, 'UNDO');
}
return true;
}
redo() {
if (this.future.length === 0) return false;
const nextState = this.future.shift();
this.history.push(nextState);
this.currentState = nextState;
// Call onEntry for restored state
if (this.states[this.currentState].onEntry) {
this.states[this.currentState].onEntry({}, 'REDO');
}
return true;
}
getHistory() {
return [...this.history];
}
}
I've used this pattern extensively in document editors where tracking state history is essential for providing undo functionality.
Visualization Tools
Visualizing state machines helps me communicate complex state logic with my team and identify potential issues early. Here's a simple visualization tool I've used:
function createStateMachineDiagram(machine) {
let diagram = 'digraph StateMachine {\n';
diagram += ' rankdir=LR;\n';
// Add states
Object.keys(machine.states).forEach(state => {
diagram += ` ${state} [shape=box];\n`;
});
// Add initial state indicator
diagram += ` "" [shape=point];\n`;
diagram += ` "" -> ${machine.initialState};\n`;
// Add transitions
Object.entries(machine.states).forEach(([stateName, stateConfig]) => {
if (stateConfig.transitions) {
Object.entries(stateConfig.transitions).forEach(([event, target]) => {
// Handle function transitions
const targetState = typeof target === 'function' ? '(dynamic)' : target;
diagram += ` ${stateName} -> ${targetState} [label="${event}"];\n`;
});
}
});
diagram += '}';
return diagram;
}
This function generates a DOT language representation that can be rendered using Graphviz or online tools to create visual diagrams.
I often integrate this with documentation tools to automatically generate up-to-date state machine visualizations as part of our project documentation.
Composable Hierarchical States
For complex applications, flat state machines become unwieldy. Hierarchical state machines allow nesting states and inheriting transitions.
class HierarchicalStateMachine {
constructor(config) {
this.states = config.states;
this.initialState = config.initialState;
this.currentStateStack = this.initialState.split('.');
}
get currentState() {
return this.currentStateStack.join('.');
}
dispatch(event, payload = {}) {
// Try to handle event at current state level
let handled = false;
let tempStack = [...this.currentStateStack];
// Try each level of the hierarchy, starting with most specific
while (tempStack.length > 0 && !handled) {
const statePath = tempStack.join('.');
const stateConfig = this.getStateConfig(statePath);
if (stateConfig && stateConfig.transitions && stateConfig.transitions[event]) {
const transition = stateConfig.transitions[event];
const targetState = typeof transition === 'function'
? transition(payload, this.currentState)
: transition;
if (targetState) {
// Exit states
this.exitToCommonAncestor(targetState);
// Update current state
this.currentStateStack = targetState.split('.');
// Enter states
this.enterFromAncestor(targetState);
handled = true;
}
}
// Move up one level in hierarchy
tempStack.pop();
}
return handled;
}
getStateConfig(statePath) {
const parts = statePath.split('.');
let current = this.states;
for (const part of parts) {
if (!current[part]) return null;
current = current[part];
}
return current;
}
exitToCommonAncestor(targetState) {
const targetParts = targetState.split('.');
let commonPrefix = [];
// Find common ancestor
for (let i = 0; i < Math.min(this.currentStateStack.length, targetParts.length); i++) {
if (this.currentStateStack[i] === targetParts[i]) {
commonPrefix.push(this.currentStateStack[i]);
} else {
break;
}
}
// Exit states from current to common ancestor
for (let i = this.currentStateStack.length - 1; i >= commonPrefix.length; i--) {
const statePath = this.currentStateStack.slice(0, i + 1).join('.');
const stateConfig = this.getStateConfig(statePath);
if (stateConfig && stateConfig.onExit) {
stateConfig.onExit();
}
}
}
enterFromAncestor(targetState) {
const targetParts = targetState.split('.');
const currentParts = this.currentStateStack.join('.').split('.');
let commonPrefixLength = 0;
// Find common prefix length
for (let i = 0; i < Math.min(currentParts.length, targetParts.length); i++) {
if (currentParts[i] === targetParts[i]) {
commonPrefixLength++;
} else {
break;
}
}
// Enter states from common ancestor to target
for (let i = commonPrefixLength; i < targetParts.length; i++) {
const statePath = targetParts.slice(0, i + 1).join('.');
const stateConfig = this.getStateConfig(statePath);
if (stateConfig && stateConfig.onEntry) {
stateConfig.onEntry();
}
}
}
}
This implementation supports states like "app.form.editing" and automatically handles entering and exiting intermediate states when transitioning.
I've used hierarchical state machines for complex workflows like multi-step forms and application navigation with nested UI states.
Pure Transition Functions
The final approach emphasizes functional programming principles by implementing state transitions as pure functions. This makes testing easier and simplifies debugging.
function createPureStateMachine(initialState, transitionFn) {
let state = initialState;
const listeners = new Set();
function getState() {
return state;
}
function dispatch(event, payload = {}) {
const nextState = transitionFn(state, event, payload);
if (nextState !== state) {
const prevState = state;
state = nextState;
// Notify listeners
listeners.forEach(listener => {
listener(state, prevState, event, payload);
});
return true;
}
return false;
}
function subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
return { getState, dispatch, subscribe };
}
The transition function should be a pure function that calculates the next state based on the current state and event:
function trafficLightTransition(state, event) {
const transitions = {
red: { TIMER: 'green' },
yellow: { TIMER: 'red' },
green: { TIMER: 'yellow' }
};
return transitions[state]?.[event] || state;
}
const trafficLight = createPureStateMachine('red', trafficLightTransition);
// Use the state machine
console.log(trafficLight.getState()); // "red"
trafficLight.dispatch('TIMER'); // true
console.log(trafficLight.getState()); // "green"
I often use this approach when working with Redux or other state management libraries, as it integrates well with their paradigms.
Practical Application Example
Let's see these approaches in action with a real-world example: a payment processing flow.
const paymentMachine = {
initialState: 'idle',
states: {
idle: {
transitions: {
START_PAYMENT: 'processing',
},
onEntry: () => {
// Reset payment form
document.getElementById('payment-form').reset();
document.getElementById('payment-section').className = 'active';
document.getElementById('processing-section').className = 'hidden';
document.getElementById('success-section').className = 'hidden';
document.getElementById('error-section').className = 'hidden';
}
},
processing: {
transitions: {
PAYMENT_SUCCESS: 'success',
PAYMENT_ERROR: 'error',
CANCEL: 'idle'
},
onEntry: (_, __, payload) => {
// Show processing UI
document.getElementById('payment-section').className = 'hidden';
document.getElementById('processing-section').className = 'active';
// Process payment
processPayment(payload.paymentDetails)
.then(() => paymentProcessor.dispatch('PAYMENT_SUCCESS'))
.catch(error => paymentProcessor.dispatch('PAYMENT_ERROR', { error }));
}
},
success: {
transitions: {
NEW_PAYMENT: 'idle'
},
onEntry: () => {
// Show success message
document.getElementById('processing-section').className = 'hidden';
document.getElementById('success-section').className = 'active';
}
},
error: {
transitions: {
RETRY: 'processing',
CANCEL: 'idle'
},
onEntry: (_, __, payload) => {
// Show error message
document.getElementById('processing-section').className = 'hidden';
document.getElementById('error-section').className = 'active';
document.getElementById('error-message').textContent =
payload.error?.message || 'Unknown error occurred';
}
}
}
};
const paymentProcessor = createMachine(paymentMachine);
// Event handlers
document.getElementById('payment-button').addEventListener('click', () => {
const paymentDetails = {
cardNumber: document.getElementById('card-number').value,
expiry: document.getElementById('expiry').value,
cvv: document.getElementById('cvv').value,
amount: document.getElementById('amount').value
};
paymentProcessor.transition('START_PAYMENT', { paymentDetails });
});
document.getElementById('retry-button').addEventListener('click', () => {
paymentProcessor.transition('RETRY');
});
document.getElementById('cancel-button').addEventListener('click', () => {
paymentProcessor.transition('CANCEL');
});
document.getElementById('new-payment-button').addEventListener('click', () => {
paymentProcessor.transition('NEW_PAYMENT');
});
This implementation shows how a state machine can orchestrate UI changes and API calls in a payment flow, keeping the state logic centralized and predictable.
Conclusion
I've implemented each of these state machine approaches in various projects. The right approach depends on your specific needs:
- Plain object definitions are great for simple state machines
- Event handlers work well when you need custom transition logic
- State history is essential for applications requiring undo functionality
- Visualization helps with documenting complex state machines
- Hierarchical states excel in complex workflows with nested states
- Pure transition functions integrate well with functional programming paradigms
By understanding these different approaches, you can select the right pattern for your specific use case and build more maintainable, predictable JavaScript applications. State machines provide a clear mental model for managing complexity and help prevent the bugs that often occur with ad-hoc state management approaches.
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)