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
7 min read
Sponsored
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.
createMachineis leaner. Theschema,context, andtsTypesblocks from v4 are gone. TypeScript inference works without them.setup()for typing. A newsetup()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/reactis cleaner:useMachineanduseActorare 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
placingwithout an address and payment method. Guards enforce it. - You cannot go back from
placingmid-flight. The state has noBACKtransition. successis 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
isLoadingandisErrorbeing 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})withsetup({actions, guards, actors}).createMachine({...}) - Rename
servicestoactorsthroughout - Replace
interpret(machine)withcreateActor(machine) - Replace
service.start()calls withactor.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
More from this category
More from Web Development
SolidJS in 2026: Fine-Grained Reactivity Without the Virtual DOM
Three.js and React Three Fiber: 3D on the Web Without the Pain
TanStack Router in 2026: Type-Safe Routing That Rewires How You Think About Navigation
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored