Skip to content

Class-Based State Machines

When to use

Use this class-based state machine when a workflow has explicit states, defined transitions, and side effects that must run on entry or exit. Common cases: multi-step UI flows (wizards, imports, onboarding), loading indicators with timed transitions, processes with retry and failure handling, and any logic where ad-hoc boolean flags become unmanageable.

The pattern

A State base class (extending EventEmitter) defines the lifecycle contract: enter(), exit(), handleEvent(), and transition(). A StateMachine class manages the current state, wires lifecycle events, and provides dispatch() for external input.

Using the machine

Create a StateMachine by passing it a state class with a static initial() method. Listen for "transition" events to react to state changes, and call dispatch() to send events to the current state:

javascript
const machine = new StateMachine(FetchState);

machine.on("transition", () => {
  console.log(
    "State:",
    machine.currentState.constructor.name,
  );
});

machine.dispatch({
  type: "fetch",
  url: "/api/users",
});

Defining states

Subclass a base state class, then create concrete states that implement handleEvent() and call this.transition():

javascript
class FetchState extends State {
  static initial() {
    return new Idle();
  }
}

class Idle extends FetchState {
  handleEvent(event) {
    if (event.type === "fetch") {
      this.transition(new Fetching(event.url));
    }
  }
}

class Fetching extends FetchState {
  constructor(url) {
    super();
    this.url = url;
  }
  
  async enter() {
    try {
      const response = await fetch(this.url);
      const data = await response.json();
      this.transition(new Success(data));
    } catch (error) {
      this.transition(new Failure(error));
    }
  }
  
  handleEvent() {}
}

class Success extends FetchState {
  constructor(data) {
    super();
    this.data = data;
  }
  
  get final() {
    return true;
  }
  
  handleEvent() {}
}

class Failure extends FetchState {
  constructor(error) {
    super();
    this.error = error;
  }
  
  handleEvent(event) {
    if (event.type === "retry") {
      this.transition(new Fetching(event.url));
    }
  }
}

Timed transitions with active guard

Async enter() hooks can use this.active to check whether the state is still current before transitioning. This prevents stale transitions when a state is exited before an async operation completes:

javascript
class Appearing extends LoadingState {
  async enter() {
    await delay(200);
    if (this.active) {
      this.transition(new Visible());
    }
  }
  
  handleEvent(event) {
    if (event.type === "progress" && event.done) {
      this.transition(new Disappearing());
    }
  }
}

Shared context

The context object on the machine is passed to every handleEvent() call and to enter/exit events. Use it for data that needs to survive across state transitions:

javascript
machine.context.retryCount = 0;

class Failure extends FetchState {
  handleEvent(event, context) {
    if (event.type === "retry") {
      context.retryCount += 1;
      this.transition(new Fetching(event.url));
    }
  }
}

Implementing State

The State base class extends EventEmitter and defines the lifecycle contract. It wires enter and exit events in the constructor to track active status, and exposes handleEvent() for subclasses to override and transition() to signal a state change.

javascript
import EventEmitter from "eventemitter3";

export class State extends EventEmitter {
  static initial() {
    throw new Error("static initial() not implemented");
  }
  
  _active = false;
  
  get active() {
    return this._active;
  }
  
  get final() {
    return false;
  }
  
  constructor() {
    super();
    this.on("enter", (event) => {
      this._active = true;
      this.enter?.(event);
    });
    this.on("exit", (event) => {
      this._active = false;
      this.exit?.(event);
    });
  }
  
  handleEvent(event) {
    throw new Error(
      "handleEvent(event) not implemented"
    );
  }
  
  async transition(nextState) {
    this.emit("transition", nextState);
  }
}

Implementing StateMachine

The StateMachine class manages the current state instance, validates transitions with an instanceof check, and emits "transition" events for external listeners. dispatch() forwards events to the current state and is a no-op when the state is final.

javascript
import EventEmitter from "eventemitter3";

export class StateMachine extends EventEmitter {
  context = {};
  #currentState;
  #StateClass;
  #transitionHandler = (newState) => {
    this.transition(newState);
  };
  
  constructor(StateClass) {
    super();
    this.#StateClass = StateClass;
    this.transition(StateClass.initial());
  }
  
  get currentState() {
    return this.#currentState;
  }
  
  get StateClass() {
    return this.#StateClass;
  }
  
  dispatch(event) {
    if (this.currentState.final) return;
    this.currentState.handleEvent(
      event,
      this.context,
    );
  }
  
  transition(nextState) {
    if (!(nextState instanceof this.#StateClass)) {
      throw new Error("Invalid state");
    }
    this.#exitState(this.currentState);
    this.#enterState(nextState);
    this.emit("transition", {
      type: "transition",
      context: this.context,
    });
  }
  
  #enterState(nextState) {
    this.#currentState = nextState;
    nextState.on(
      "transition",
      this.#transitionHandler,
    );
    nextState.emit("enter", {
      type: "enter",
      context: this.context,
    });
  }
  
  #exitState(previousState) {
    previousState?.off?.(
      "transition",
      this.#transitionHandler,
    );
    previousState?.emit?.("exit", {
      type: "exit",
      context: this.context,
    });
  }
  
  reset() {
    this.transition(this.#StateClass.initial());
  }
}

Trade-offs

  • Pros: Each state is a self-contained class with clear entry/exit hooks. Transitions are explicit and validated (instanceof check). Event-driven — UI layers can listen to "transition" events. Final states automatically stop event dispatch.
  • Cons: Requires eventemitter3 dependency. More ceremony than a simple switch statement for trivial two-state workflows. Each state is a new class, which adds file count.
  • Versus switch/if chains: Use a state machine when you have more than two or three states, when states have entry/exit side effects, or when invalid transitions must be caught. Use switch when the state logic fits in a single function.
  • Versus XState: This pattern is lighter weight and has no DSL to learn. XState offers visualization, serialization, and formal statechart features. Choose based on complexity.