DEV Community

Cover image for 6 Practical JavaScript State Machine Patterns for Complex Application Logic
Aarav Joshi
Aarav Joshi

Posted on

6 Practical JavaScript State Machine Patterns for Complex Application Logic

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')
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs