Appearance
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 (
instanceofcheck). Event-driven — UI layers can listen to"transition"events. Final states automatically stop event dispatch. - Cons: Requires
eventemitter3dependency. More ceremony than a simpleswitchstatement 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
switchwhen 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.