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, faster, and easier to read than v4. Here's how state machines actually help in production UI, and what the migration looks like.

Anurag Verma

Anurag Verma

7 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.

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:

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

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.

Sponsored

Sponsored

Discussion

Join the conversation.

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

Sponsored