jsguides

State Machines in JavaScript Using XState

What is a state machine?

A state machine is a mathematical model that describes an object or system behaving according to a finite number of states. At any given moment, the system exists in exactly one state. It can transition to another state when triggered by specific events.

State machines are everywhere in software: a login form can be in “idle”, “submitting”, “success”, or “error” states. A media player can be “playing”, “paused”, or “stopped”. An order fulfillment system moves through “pending”, “processing”, “shipped”, and “delivered”.

The key benefit is predictability. With explicit states and allowed transitions, you can visualize exactly what can happen and when. There’s no ambiguity about whether a “paused” player can transition directly to “shipped.” It cannot, because that transition isn’t defined.

Core Concepts

Understanding state machines requires grasping five fundamental concepts:

State represents a discrete condition of the system. States are mutually exclusive: a machine is in one state at a time, never multiple. In XState, states are defined as objects within the states property.

Event is a trigger that causes a transition between states. Events are the input to the state machine. When an event occurs, the machine evaluates whether a transition should occur based on its current state.

Transition defines the relationship between states. It specifies which state to move to when a particular event occurs in a particular source state. Transitions are deterministic: the same state and event always produce the same result.

Action is side effect that occurs during a transition. Entry actions run when entering a state. Exit actions run when leaving a state. Transition actions run during the transition itself.

Guard is a conditional check that must evaluate to true for a transition to occur. Guards enable decision-making within the state machine, allowing different transitions from the same event based on current conditions.

XState Basics

XState is the most popular state machine library for JavaScript and TypeScript. It provides a powerful, declarative API for defining and interpreting state machines.

Installing XState

npm install xstate
// value: xstate@5.x installed

Your first state machine

The most basic state machine is a toggle with two states. The machine starts in the off state, and a single TOGGLE event flips it to the opposite state. You define the possible states as keys under states, then map each event to its target state within the on property of the current state. Here’s what that looks like:

import { createMachine, interpret } from 'xstate';

const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'off',
  states: {
    off: {
      on: { TOGGLE: 'on' }
    },
    on: {
      on: { TOGGLE: 'off' }
    }
  }
});

const toggleService = interpret(toggleMachine).start();
// value: Machine started in 'off' state

Sending events

The createMachine function defines the shape of the machine, but the machine does not react until you start an interpreter for it. The interpret function creates a running instance, called a service, that tracks the current state and responds to events. Once the service is running, you drive transitions by sending event objects:

toggleService.send({ type: 'TOGGLE' });
// value: Machine transitioned to 'on' state

toggleService.send({ type: 'TOGGLE' });
// value: Machine transitioned to 'off' state

The TOGGLE event causes the machine to transition between states based on its current state. This is deterministic: sending TOGGLE from “off” always goes to “on”. This predictability is the essence of state machines. Every state-event pair maps to exactly one next state, so reasoning about the system’s behavior becomes mechanical rather than speculative.

Practical example: login form

Let’s build a login form with explicit states for each phase:

const loginMachine = createMachine({
  id: 'login',
  initial: 'idle',
  states: {
    idle: {
      on: { SUBMIT: 'submitting' }
    },
    submitting: {
      invoke: {
        src: 'performLogin',
        onDone: 'success',
        onError: 'error'
      }
    },
    success: {
      on: { RESET: 'idle' }
    },
    error: {
      on: { RETRY: 'submitting', RESET: 'idle' }
    }
  }
}, {
  services: {
    performLogin: (context) => {
      return fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(context.credentials)
      }).then(response => {
        if (!response.ok) throw new Error('Login failed');
        return response.json();
      });
    }
  }
});

This machine makes the login flow explicit. From “idle”, only “submitting” is reachable. From “submitting”, the only outcomes are “success” or “error.” No mysterious intermediate states exist. The user can retry from “error” or reset back to “idle”.

Guards and context

State machines become powerful when you add conditional logic through guards and store data in context.

Adding Context

Context holds the data associated with the machine:

const orderMachine = createMachine({
  id: 'order',
  initial: 'pending',
  context: {
    items: [],
    total: 0
  },
  states: {
    pending: {
      on: { PLACE_ORDER: 'processing' }
    },
    processing: {
      on: {
        COMPLETE: {
          target: 'completed',
          guard: ({ context }) => context.items.length > 0
        },
        CANCEL: 'cancelled'
      }
    },
    completed: {},
    cancelled: {}
  }
});

Using guards

The orderMachine above shows context in action: data stored alongside the machine that guards can inspect. Guards are pure functions that receive the current context and the event, returning a boolean. A guarded transition only fires when its guard returns true, which lets you encode business rules directly in the state diagram. Here is an example with payment validation:

const paymentMachine = createMachine({
  id: 'payment',
  initial: 'awaitingPayment',
  context: { balance: 100 },
  states: {
    awaitingPayment: {
      on: {
        PROCESS_PAYMENT: [
          { target: 'processing', guard: ({ context }) => context.balance >= 50 },
          { target: 'insufficientFunds' }
        ]
      }
    },
    processing: {},
    insufficientFunds: {
      on: { ADD_FUNDS: 'awaitingPayment' }
    }
  }
});

The PROCESS_PAYMENT event has two possible transitions. The first, with the guard, checks if the balance is sufficient. If true, go to “processing”. Otherwise, fall through to “insufficientFunds”.

Visualization

One of XState’s strongest features is the visualizer. You can paste your machine configuration into the XState Visualizer and see an interactive state diagram.

The diagram shows:

  • Boxes for each state
  • Arrows for transitions
  • Labels for events that trigger transitions
  • Guard conditions on transition arrows

This visualization is invaluable for understanding complex flows and communicating with team members. You can spot impossible states at a glance: states that have no incoming transitions, or transitions that seem unnecessary.

When to use state machines

State machines shine when:

Complex conditional logic exists. When your code has many if statements checking current state, a state machine makes the logic explicit and visualizable.

You need audit trails. Since transitions are explicit and deterministic, you can log exactly what happened and when.

Multiple actors interact. When several components or users can affect the same workflow, state machines prevent impossible states.

You need testability. Each state and transition can be tested in isolation.

However, don’t reach for state machines for simple boolean flags. A toggle button doesn’t need XState; a simple boolean works fine. The complexity is only worth it when the state space justifies it.

Model the error paths

One of the nicest uses for a state machine is modeling unhappy paths that would otherwise hide inside catch blocks and ad hoc flags. If a request can fail, retry, and eventually settle into a terminal state, express those steps directly. That makes the machine easier to read and easier to test. Instead of asking “what if this boolean is stale?”, you can ask “what event should move the machine next?” The answer is usually clearer when the states are named.

Test transitions, not just states

State machine tests are strongest when they check that a given event moves the machine to the expected next state. That sounds obvious, but it changes the shape of the test suite. You stop testing implementation details and start testing allowed transitions. This style gives you confidence that a refactor did not accidentally create a path that should not exist. It also helps when you add a new state, because the missing transitions show up as test failures instead of hidden branches.

Keep the machine small

A state machine does not need to model every tiny UI detail. If a value can live in ordinary component state without making the flow confusing, keep it there. Reserve the machine for the places where order matters, where events are meaningful, or where a diagram would help the team discuss the flow. That restraint keeps the machine readable and avoids turning a simple feature into a large graph with little payoff. State machines work best when they model the real problem without overcomplicating it.

See Also