Skip to content

Web Development · Frontend Architecture

XState v5: State Machines for UI That's Too Complex to Wing It

XState v5 ships a rewritten API that's smaller and easier to read than v4. Here's how state machines help in production UI, and what the migration looks like.

Anurag Verma

Anurag Verma

9 min read

XState v5: State Machines for UI That's Too Complex to Wing It

Sponsored

Share

Multi-step forms that can break in 12 different ways. Async operations that need loading, error, retry, and success states. Modals that open from four different places and close in three. This is the kind of UI that makes you dread opening the component file.

The standard approach is a pile of useState calls and useEffect dependencies that grow until nobody on the team fully understands what’s possible from what state. The machine works until it doesn’t, and debugging means mentally simulating all the boolean combinations to find the broken one.

State machines are the alternative. Instead of tracking individual boolean flags, you model the UI as a finite set of states with explicit transitions between them. Impossible states become impossible to reach. Transitions are named and auditable. And with XState v5, the API is clean enough to use without feeling like you’ve added a new abstraction layer on top of everything. It’s a different tool from the signal- and store-based approaches covered in our guides to Zustand and Jotai for React state and the TC39 Signals proposal; those manage arbitrary shared state, while XState specifically models finite, explicit transitions.

What Changed in v5

XState v4 was mature and widely used, but the API surface was large. Actors, services, spawned machines, and interpreters all existed as separate concepts with different APIs. Typing was awkward. The learning curve was real.

v5, released in 2023 and now mature in 2026, rewrites the core with a simpler model:

  • Actors everywhere. The actor model replaces the interpreter/service split. Everything is an actor. Machines, promises, observables, and callbacks all conform to the same interface.
  • createMachine is leaner. The schema, context, and tsTypes blocks from v4 are gone. TypeScript inference works without them.
  • setup() for typing. A new setup() function lets you pre-declare context types, actions, guards, and actors with full inference before defining machine logic.
  • Smaller bundle. The core is around 15KB minified, down from ~25KB in v4.
  • React integration via @xstate/react is cleaner: useMachine and useActor are the primary hooks.

A Concrete Example: Multi-Step Checkout

A checkout flow is one of the most common places teams reach for state machines. Here’s what a simplified version looks like in v5.

import { setup, assign, fromPromise } from "xstate";

interface CartItem {
  id: string;
  name: string;
  price: number;
}

interface CheckoutContext {
  items: CartItem[];
  shippingAddress: string | null;
  paymentMethod: string | null;
  orderId: string | null;
  error: string | null;
}

const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as
      | { type: "NEXT" }
      | { type: "BACK" }
      | { type: "SET_ADDRESS"; address: string }
      | { type: "SET_PAYMENT"; method: string }
      | { type: "SUBMIT" }
      | { type: "RETRY" },
  },
  actors: {
    placeOrder: fromPromise(async ({ input }: { input: CheckoutContext }) => {
      const response = await fetch("/api/orders", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          items: input.items,
          shippingAddress: input.shippingAddress,
          paymentMethod: input.paymentMethod,
        }),
      });
      if (!response.ok) throw new Error("Order failed");
      const { orderId } = await response.json();
      return orderId;
    }),
  },
  actions: {
    setAddress: assign({
      shippingAddress: ({ event }) =>
        event.type === "SET_ADDRESS" ? event.address : null,
    }),
    setPayment: assign({
      paymentMethod: ({ event }) =>
        event.type === "SET_PAYMENT" ? event.method : null,
    }),
    setOrderId: assign({
      orderId: ({ event }) =>
        event.type === "xstate.done.actor.placeOrder" ? event.output : null,
    }),
    setError: assign({
      error: ({ event }) =>
        event.type === "xstate.error.actor.placeOrder"
          ? String(event.error)
          : null,
    }),
  },
  guards: {
    hasAddress: ({ context }) => !!context.shippingAddress,
    hasPayment: ({ context }) => !!context.paymentMethod,
  },
}).createMachine({
  id: "checkout",
  initial: "shipping",
  context: {
    items: [],
    shippingAddress: null,
    paymentMethod: null,
    orderId: null,
    error: null,
  },
  states: {
    shipping: {
      on: {
        SET_ADDRESS: { actions: "setAddress" },
        NEXT: {
          target: "payment",
          guard: "hasAddress",
        },
      },
    },
    payment: {
      on: {
        SET_PAYMENT: { actions: "setPayment" },
        BACK: "shipping",
        SUBMIT: {
          target: "placing",
          guard: "hasPayment",
        },
      },
    },
    placing: {
      invoke: {
        src: "placeOrder",
        input: ({ context }) => context,
        onDone: {
          target: "success",
          actions: "setOrderId",
        },
        onError: {
          target: "error",
          actions: "setError",
        },
      },
    },
    success: { type: "final" },
    error: {
      on: {
        RETRY: "placing",
        BACK: "payment",
      },
    },
  },
});

This definition makes several things explicit that a useState approach leaves implicit:

  • You cannot reach placing without an address and payment method. Guards enforce it.
  • You cannot go back from placing mid-flight. The state has no BACK transition.
  • success is a final state. You cannot accidentally transition out of it.
  • Error handling is a named state with explicit recovery paths, not a catch block that sets a boolean.

Using the Machine in React

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

export function Checkout({ items }: { items: CartItem[] }) {
  const [state, send] = useMachine(checkoutMachine, {
    input: { items },
  });

  if (state.matches("shipping")) {
    return (
      <ShippingForm
        onAddressChange={(address) =>
          send({ type: "SET_ADDRESS", address })
        }
        onNext={() => send({ type: "NEXT" })}
        canProceed={!!state.context.shippingAddress}
      />
    );
  }

  if (state.matches("payment")) {
    return (
      <PaymentForm
        onMethodChange={(method) =>
          send({ type: "SET_PAYMENT", method })
        }
        onBack={() => send({ type: "BACK" })}
        onSubmit={() => send({ type: "SUBMIT" })}
        canSubmit={!!state.context.paymentMethod}
      />
    );
  }

  if (state.matches("placing")) {
    return <LoadingScreen message="Placing your order..." />;
  }

  if (state.matches("success")) {
    return <OrderConfirmation orderId={state.context.orderId!} />;
  }

  if (state.matches("error")) {
    return (
      <ErrorScreen
        message={state.context.error}
        onRetry={() => send({ type: "RETRY" })}
        onBack={() => send({ type: "BACK" })}
      />
    );
  }
}

The component becomes a display layer. It doesn’t make state decisions. It renders based on state.matches() and sends events upward. This separation makes the component easy to test: give it a state, assert the rendered output.

Testing Without a Browser

Because the machine is a pure object, you can test its transitions without React, without a DOM, and without mocking the UI — the same principle behind fast unit tests covered in our Vitest guide:

import { createActor } from "xstate";

test("cannot proceed to payment without an address", () => {
  const actor = createActor(checkoutMachine).start();
  
  actor.send({ type: "NEXT" });
  
  expect(actor.getSnapshot().value).toBe("shipping");
});

test("returns to payment after an order error", () => {
  const actor = createActor(checkoutMachine).start();
  
  actor.send({ type: "SET_ADDRESS", address: "123 Main St" });
  actor.send({ type: "NEXT" });
  actor.send({ type: "SET_PAYMENT", method: "card" });
  actor.send({ type: "SUBMIT" });
  
  // Simulate error by checking we're in placing state
  expect(actor.getSnapshot().value).toBe("placing");
});

For async actors, you can provide mock implementations via input or by replacing actors in tests:

const testMachine = checkoutMachine.provide({
  actors: {
    placeOrder: fromPromise(async () => {
      throw new Error("Payment declined");
    }),
  },
});

test("handles order failure", async () => {
  const actor = createActor(testMachine).start();
  // ... navigate to placing state ...
  await waitFor(actor, (state) => state.matches("error"));
  expect(actor.getSnapshot().context.error).toBe("Payment declined");
});

When to Use a State Machine vs useState

State machines carry setup cost. Not every component needs one.

Use a machine when:

  • A component has 4+ related state variables that change together
  • There are async operations with loading, error, and success handling
  • The same event should do different things depending on current state
  • You’ve had bugs from invalid state combinations (both isLoading and isError being true)
  • You’re writing if (isX && !isY && isZ) guards in your render logic

Stick with useState when:

  • The component is simple: a single toggle, a controlled input, a local counter
  • State doesn’t have meaningful transitions. It’s just data.
  • The component is unlikely to grow more complex

Timeouts With Delayed Transitions

A common UI requirement that useState handles awkwardly is a timeout: auto-dismiss a toast after five seconds, expire a payment session after 10 minutes, or fall back to an error state if a request hangs. XState models this natively with the after property instead of a manually managed setTimeout and cleanup function.

placing: {
  invoke: { src: "placeOrder", /* ...onDone, onError as before... */ },
  after: {
    15000: { target: "error", actions: "setTimeoutError" },
  },
},

The 15-second timer starts the moment the machine enters placing and is automatically cancelled if the state transitions away before it fires, whether that’s from onDone, onError, or any other event. There’s no useEffect cleanup to forget and no risk of a stale timeout firing after the component that set it has already moved on.

States can nest inside each other, which matters once a single step in a flow has its own sub-states. A payment step that itself needs to select a method, validate a card, and confirm 3D Secure doesn’t have to flatten into more top-level states; it can be its own hierarchy:

payment: {
  initial: "selectingMethod",
  states: {
    selectingMethod: { on: { SET_PAYMENT: "validating" } },
    validating: { on: { VALID: "confirmed", INVALID: "selectingMethod" } },
    confirmed: { type: "final" },
  },
  onDone: "placing",
},

The parent payment state only cares that its children eventually reach confirmed; it doesn’t need to know how many sub-steps that took. This keeps the top-level machine readable even as individual steps grow more complex, instead of every sub-decision becoming another flag at the top level.

The Visualizer

One practical advantage of XState is the Stately visualizer. Paste your machine definition and get an interactive diagram of states and transitions. This is useful for:

  • Explaining complex flows to non-engineers during design reviews
  • Finding unreachable states during development
  • Documenting how a feature works without writing prose

The Stately editor also lets you build machines visually and export the code, which can speed up the initial design of a new flow.

Migration from v4

If you have v4 machines, the migration is mostly mechanical:

  • Replace createMachine({...}, {actions, guards, services}) with setup({actions, guards, actors}).createMachine({...})
  • Rename services to actors throughout
  • Replace interpret(machine) with createActor(machine)
  • Replace service.start() calls with actor.start()
  • Update state.matches() calls; the API is the same but nested state matching changed slightly

The XState migration guide covers edge cases. For most machines, the change takes an hour or two.

The real payoff from using state machines isn’t in the initial build. It’s six months later when a new team member asks “what happens if the user closes the modal while the order is being placed?” and the answer is in the machine definition, not scattered across five useEffect calls.

Frequently asked questions

What's different in XState v5 compared to v4?
v5 replaces v4's separate actors, services, spawned machines, and interpreters with a single actor model, adds a `setup()` function for pre-declaring types before defining machine logic, and shrinks the core bundle from about 25KB to around 15KB minified.
When should I use a state machine instead of useState?
Reach for a state machine when a component has four or more related state variables that change together, has async operations needing loading/error/success handling, or when you catch yourself writing `if (isX && !isY && isZ)` guards. For a single toggle or controlled input with no meaningful transitions, `useState` is still the right call.
How do you test an XState machine?
Because the machine is a pure object, you can test its transitions directly with `createActor(machine).start()`, send events, and assert on `actor.getSnapshot().value`, without rendering any component or mocking the DOM. Async actors can be swapped for mocks using `machine.provide({ actors: { ... } })`.
How do I handle timeouts in an XState machine?
Use the `after` property on a state to declare a delayed transition, for example `after: { 15000: { target: 'error' } }`. The timer starts when the machine enters that state and is automatically cancelled if any other transition fires first, which avoids the manual `setTimeout`-and-cleanup pattern `useEffect` requires.
Is migrating from XState v4 to v5 difficult?
For most machines, it's mechanical: wrap `actions`/`guards`/`actors` in `setup()` before calling `createMachine()`, rename `services` to `actors`, replace `interpret(machine)` with `createActor(machine)`, and swap `.start()` calls accordingly. Most migrations take an hour or two per machine.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored