Skip to content

State Machines with XState

advanced20 min read

The Boolean Explosion Problem

You're building an authentication flow. You start simple:

const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);

Three booleans, eight possible combinations. But only four make sense:

isLoadingisErrorisAuthenticatedValid?
falsefalsefalseIdle
truefalsefalseLoading
falsetruefalseError
falsefalsetrueAuthenticated
truetruefalseLoading AND error?
truefalsetrueLoading AND authenticated?
falsetruetrueError AND authenticated?
truetruetrueAll three at once?

Half of the combinations are impossible states — but your type system allows them. And eventually, a race condition or a forgotten setIsLoading(false) puts your app in one of those impossible states. Users see a loading spinner AND an error message. Or they're "authenticated" but the loading indicator never disappears.

State machines make impossible states impossible by design.

Mental Model

A state machine is a directed graph where nodes are states and edges are transitions triggered by events. At any moment, the machine is in exactly ONE state. From that state, only specific events are valid — everything else is ignored. You can't be "loading" and "error" simultaneously because they're separate nodes, not separate booleans. It's like a traffic light: it's red OR yellow OR green, never two at once. The transitions (red to green, green to yellow, yellow to red) are explicit and exhaustive.

XState v5: The Basics

XState v5 is a complete rewrite focused on the actor model. Let's start with the fundamentals:

import { createMachine, createActor } from 'xstate';

const authMachine = createMachine({
  id: 'auth',
  initial: 'idle',
  context: {
    user: null as User | null,
    error: null as string | null,
  },
  states: {
    idle: {
      on: {
        LOGIN: 'authenticating',
      },
    },
    authenticating: {
      invoke: {
        src: 'loginService',
        input: ({ event }) => ({
          email: event.email,
          password: event.password,
        }),
        onDone: {
          target: 'authenticated',
          actions: ({ context, event }) => ({
            ...context,
            user: event.output,
            error: null,
          }),
        },
        onError: {
          target: 'error',
          actions: ({ context, event }) => ({
            ...context,
            error: event.error.message,
          }),
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: 'idle',
      },
    },
    error: {
      on: {
        RETRY: 'authenticating',
        RESET: 'idle',
      },
    },
  },
});

This machine has four states. From idle, the only valid event is LOGIN. You literally cannot trigger a logout from the idle state — the machine ignores it. From authenticating, nothing external can happen — the machine waits for the async service to resolve or reject. This is not just documentation; it's enforced logic.

Quiz
In the auth machine above, what happens if you send a LOGOUT event while in the 'authenticating' state?

The setup() Function

XState v5 introduces setup() for defining types, actions, guards, and services before creating the machine:

import { setup, assign } from 'xstate';

const checkoutMachine = setup({
  types: {
    context: {} as {
      items: CartItem[];
      shippingAddress: Address | null;
      paymentMethod: PaymentMethod | null;
      error: string | null;
    },
    events: {} as
      | { type: 'PROCEED' }
      | { type: 'BACK' }
      | { type: 'SET_ADDRESS'; address: Address }
      | { type: 'SET_PAYMENT'; method: PaymentMethod }
      | { type: 'CONFIRM' },
  },
  guards: {
    hasItems: ({ context }) => context.items.length > 0,
    hasAddress: ({ context }) => context.shippingAddress !== null,
    hasPayment: ({ context }) => context.paymentMethod !== null,
  },
  actions: {
    setAddress: assign({
      shippingAddress: ({ event }) => {
        if (event.type !== 'SET_ADDRESS') return null;
        return event.address;
      },
    }),
    setPayment: assign({
      paymentMethod: ({ event }) => {
        if (event.type !== 'SET_PAYMENT') return null;
        return event.method;
      },
    }),
    clearError: assign({ error: null }),
  },
}).createMachine({
  id: 'checkout',
  initial: 'cart',
  context: {
    items: [],
    shippingAddress: null,
    paymentMethod: null,
    error: null,
  },
  states: {
    cart: {
      on: {
        PROCEED: {
          target: 'shipping',
          guard: 'hasItems',
        },
      },
    },
    shipping: {
      on: {
        SET_ADDRESS: { actions: 'setAddress' },
        PROCEED: { target: 'payment', guard: 'hasAddress' },
        BACK: 'cart',
      },
    },
    payment: {
      on: {
        SET_PAYMENT: { actions: 'setPayment' },
        CONFIRM: { target: 'processing', guard: 'hasPayment' },
        BACK: 'shipping',
      },
    },
    processing: {
      invoke: {
        src: 'processOrder',
        onDone: 'confirmation',
        onError: {
          target: 'payment',
          actions: assign({
            error: ({ event }) => event.error.message,
          }),
        },
      },
    },
    confirmation: {
      type: 'final',
    },
  },
});

The setup() function gives you full TypeScript inference for events, context, guards, and actions. No more as any or manual type assertions.

Guards: Conditional Transitions

Guards prevent transitions unless a condition is met:

states: {
  cart: {
    on: {
      PROCEED: {
        target: 'shipping',
        guard: 'hasItems',
      },
    },
  },
}

If hasItems returns false, the PROCEED event is ignored. The machine stays in cart. This is how you enforce business rules at the state level — you can't check out with an empty cart, period.

Quiz
You have a checkout machine in the 'shipping' state with a guard hasAddress on the PROCEED transition. The user clicks 'Next' without entering an address. What happens?

Actors: The XState v5 Core

XState v5 centers on the actor model. Machines are used to create actors — running instances with their own state, behavior, and lifecycle:

import { createActor } from 'xstate';

const checkoutActor = createActor(checkoutMachine, {
  input: { items: cartItems },
});

checkoutActor.subscribe((snapshot) => {
  console.log('State:', snapshot.value);
  console.log('Context:', snapshot.context);
});

checkoutActor.start();
checkoutActor.send({ type: 'PROCEED' });

In React, use the useMachine hook from @xstate/react:

import { useMachine } from '@xstate/react';

function CheckoutFlow() {
  const [snapshot, send] = useMachine(checkoutMachine);

  switch (snapshot.value) {
    case 'cart':
      return <CartView items={snapshot.context.items} onProceed={() => send({ type: 'PROCEED' })} />;
    case 'shipping':
      return <ShippingForm onSubmit={(addr) => send({ type: 'SET_ADDRESS', address: addr })} />;
    case 'payment':
      return <PaymentForm onSubmit={(method) => send({ type: 'SET_PAYMENT', method })} />;
    case 'processing':
      return <ProcessingSpinner />;
    case 'confirmation':
      return <OrderConfirmation />;
    default:
      return null;
  }
}

The switch over snapshot.value is exhaustive — TypeScript ensures you handle every state. Miss one and you get a compile error.

When State Machines Solve Real Problems

State machines add upfront design cost. They're overkill for a sidebar toggle. But they pay for themselves in specific scenarios:

Complex UI Flows with Strict Ordering

Multi-step wizards, onboarding flows, checkout processes — anywhere the user must follow a specific sequence with conditional branching and back-tracking.

Async Processes with Error Recovery

Payment processing, file uploads, API orchestration — anywhere you need clear handling of pending, success, failure, retry, and cancellation states.

Impossible State Prevention

Any feature where the combination of boolean flags creates invalid states. If you've ever debugged a "loading AND error simultaneously" bug, that's a state machine screaming to be born.

The ad-hoc boolean approach and its failure modes

Consider a file upload component with these states: idle, selecting, uploading, processing, success, error. With booleans:

const [isSelecting, setIsSelecting] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

Five booleans = 32 possible combinations. Only 6 are valid. You now need to ensure that every state transition correctly sets/unsets all 5 booleans. Miss one setIsUploading(false) and you have a phantom loading state. With a state machine, the machine is in exactly one of 6 states. Period.

The Visual Editor

One of XState's unique strengths is the Stately visual editor at stately.ai. You can:

  1. Design machines visually by dragging states and drawing transitions
  2. Simulate machines — click through states to verify behavior
  3. Export to code — the visual design generates XState v5 code
  4. Import from code — paste your machine and see the visual representation

This visual aspect is invaluable for communicating complex flows with designers, product managers, and QA. The state diagram IS the spec — and it's executable.

Quiz
Your team is debating whether to use XState for a simple dark/light theme toggle. What's the right call?

Comparing with Ad-Hoc Boolean State

AspectBoolean FlagsXState State Machine
State representationN booleans = 2^N possible combinationsN explicit states, all valid
Impossible statesPossible — must be prevented manuallyImpossible by design
TransitionsImplicit — any setState call from anywhereExplicit — only defined transitions are valid
VisualizationNone — logic scattered across handlersVisual state diagram, auto-generated or designed
TypeScript supportBoolean checks, no exhaustivenessDiscriminated unions, exhaustive matching
Async processesManual isLoading/isError managementinvoke with built-in onDone/onError
DebuggingConsole.log scattered everywhereState inspection, event history, visual simulation
Best forSimple toggles, 1-2 states4+ states with complex transitions
What developers doWhat they should do
Using XState for every piece of state in the application
State machines add upfront design cost. For a boolean toggle or a simple counter, that cost exceeds the benefit. Reserve XState for flows where impossible states, strict ordering, or async orchestration matter.
Use XState only for complex flows with multiple states, transitions, and guards. Use useState/Zustand for simple state.
Using XState v4 API (Machine, interpret) in new projects
XState v5 is a complete rewrite with better TypeScript support, the actor model, and the setup() function for type-safe machine definitions. v4 patterns don't translate directly.
Use XState v5 API: setup(), createMachine, createActor
Putting server-fetched data in XState context instead of using TanStack Query
XState has no caching, deduplication, or background refetching. Use it for orchestrating flows, not for caching API responses.
XState manages flow logic (which step, what transitions are valid). Server data lives in TanStack Query.
Key Rules
  1. 1State machines make impossible states impossible by design. Use them when boolean combinations create invalid states.
  2. 2XState v5 uses the actor model: createMachine defines logic, createActor creates a running instance.
  3. 3setup() provides full TypeScript inference for events, context, guards, and actions. Always use it in v5.
  4. 4Guards prevent transitions conditionally — they enforce business rules at the state level.
  5. 5Use XState for complex flows (4+ states, async, conditional branching). Use useState for simple toggles.
  6. 6The visual editor at stately.ai turns state machines into living documentation — share with designers and PMs.