XState v5 in a React App: When State Machines Beat useState and Zustand

TL;DR — Use XState v5 for flows with distinct steps and forbidden transitions — checkout, payment, wizards, video players — and stop faking state machines with four booleans and a useEffect. Keep Zustand or React Query for shared app state; the two solve different problems and most apps want both.

XState v5 sits at 29k stars, ships a slimmer runtime, and has a first-class React binding via @xstate/react. Most React devs still reach for useState plus useReducer for flows that are, honestly, state machines wearing a trench coat. Multi-step checkouts, payment retries, video players, wizards, uploads with pause/resume — every one of them has explicit states and forbidden transitions, and every one of them ends up as a soup of booleans if you fake it with hooks.

This is a working guide to xstate v5 in a React app: a real checkout refactor from tangled useState to a machine, a clear rule for when the machine pays for itself, and an honest take on xstate vs Zustand so you stop mixing them up.

react developer code
react developer code

What is XState and why use it in React?

XState: a finite state machine and statechart library for JavaScript that models UI logic as explicit states, events, and transitions — instead of a bag of boolean flags. In React, you wire it up with useMachine from @xstate/react, and the machine becomes the single source of truth for what the UI can and cannot do next.

The value is not "another store". The value is that impossible states become impossible. You cannot be loading and error and success at once, because those are three values of one state, not three independent booleans. That alone kills the class of bugs where the spinner keeps spinning after the request errored.

The useState checkout that everyone writes first

Here is the flow: collect shipping, collect payment, submit, handle 3DS challenge, show receipt. Retry on failure. Cancel at any step. The naive React version starts fine and rots by week two.

function Checkout() {
  const [step, setStep] = useState('shipping');
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState(null);
  const [needs3ds, setNeeds3ds] = useState(false);
  const [threeDsUrl, setThreeDsUrl] = useState(null);
  const [orderId, setOrderId] = useState(null);
  const [shipping, setShipping] = useState(null);
  const [payment, setPayment] = useState(null);

  const submit = async () => {
    setIsSubmitting(true);
    setError(null);
    try {
      const res = await createOrder({ shipping, payment });
      if (res.requires3ds) {
        setNeeds3ds(true);
        setThreeDsUrl(res.url);
      } else {
        setOrderId(res.id);
        setStep('receipt');
      }
    } catch (e) {
      setError(e.message);
    } finally {
      setIsSubmitting(false);
    }
  };
  // ...and now render 5 branches over 8 booleans
}

Eight independent state variables. That is 2^8 = 256 combinations the type system says are legal. Roughly a dozen are actually valid. The rest are the bugs your QA finds: submit spinner during 3DS, error banner on the receipt page, back button that lands you in a state the API cannot recover from. The docs don't mention this because there are no docs — every team invents this mess fresh.

The same checkout as an XState v5 machine

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

export const checkoutMachine = setup({
  types: {
    context: {} as {
      shipping: Shipping | null;
      payment: Payment | null;
      orderId: string | null;
      threeDsUrl: string | null;
      error: string | null;
    },
    events: {} as
      | { type: 'SUBMIT_SHIPPING'; data: Shipping }
      | { type: 'SUBMIT_PAYMENT'; data: Payment }
      | { type: 'CONFIRM' }
      | { type: 'THREEDS_COMPLETE' }
      | { type: 'RETRY' }
      | { type: 'CANCEL' },
  },
  actors: {
    createOrder: fromPromise(async ({ input }) => {
      return api.createOrder(input);
    }),
  },
}).createMachine({
  id: 'checkout',
  initial: 'shipping',
  context: { shipping: null, payment: null, orderId: null, threeDsUrl: null, error: null },
  states: {
    shipping: {
      on: { SUBMIT_SHIPPING: { target: 'payment', actions: assign({ shipping: ({ event }) => event.data }) } },
    },
    payment: {
      on: {
        SUBMIT_PAYMENT: { target: 'review', actions: assign({ payment: ({ event }) => event.data }) },
        BACK: 'shipping',
      },
    },
    review: {
      on: { CONFIRM: 'submitting', BACK: 'payment' },
    },
    submitting: {
      invoke: {
        src: 'createOrder',
        input: ({ context }) => ({ shipping: context.shipping, payment: context.payment }),
        onDone: [
          { guard: ({ event }) => event.output.requires3ds, target: 'threeDs',
            actions: assign({ threeDsUrl: ({ event }) => event.output.url }) },
          { target: 'success',
            actions: assign({ orderId: ({ event }) => event.output.id }) },
        ],
        onError: { target: 'failure', actions: assign({ error: ({ event }) => String(event.error) }) },
      },
    },
    threeDs: {
      on: { THREEDS_COMPLETE: 'submitting' },
    },
    failure: {
      on: { RETRY: 'submitting', CANCEL: 'review' },
    },
    success: { type: 'final' },
  },
});

And in React:

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

function Checkout() {
  const [state, send] = useMachine(checkoutMachine);

  if (state.matches('shipping')) return <ShippingForm onSubmit={(data) => send({ type: 'SUBMIT_SHIPPING', data })} />;
  if (state.matches('payment')) return <PaymentForm onSubmit={(data) => send({ type: 'SUBMIT_PAYMENT', data })} />;
  if (state.matches('review')) return <Review onConfirm={() => send({ type: 'CONFIRM' })} />;
  if (state.matches('submitting')) return <Spinner />;
  if (state.matches('threeDs')) return <ThreeDsFrame url={state.context.threeDsUrl!} onDone={() => send({ type: 'THREEDS_COMPLETE' })} />;
  if (state.matches('failure')) return <Error message={state.context.error!} onRetry={() => send({ type: 'RETRY' })} />;
  if (state.matches('success')) return <Receipt orderId={state.context.orderId!} />;
  return null;
}

Notice what disappeared: the boolean matrix, the finally block juggling flags, the ordering bugs. The RETRY event only exists in failure. The submit spinner only exists in submitting. Send THREEDS_COMPLETE from shipping and nothing happens — the machine ignores events that are not defined on the current state. That is the whole point.

state machine diagram
state machine diagram

XState vs Zustand: which one solves your problem?

Verdict up front: use Zustand for shared application state (cart contents, user session, theme). Use XState for flows with distinct steps and forbidden transitions (checkout, onboarding, payment, video player, file upload with pause/resume). They are not competitors. Most non-trivial apps want both.

Zustand is a store: a single mutable object with reactive selectors. It answers "where does this data live and how do components read it". It has nothing to say about whether a transition is legal. If your bug is "the retry button showed up before the request failed", Zustand will not save you — a boolean will still be a boolean.

XState is a coordinator. It answers "what can happen next, given where we are". Its store (the context) is deliberately smaller than a Zustand store because most global state is not step-based. Wiring a whole app through one giant machine is the mistake people make on their first XState project — the second project usually mixes both, and that is the right shape.

For evaluating other state options, our 2026 React project setup guide covers where TanStack Query, Zustand, and a machine each fit in the modern stack.

When does XState pay for itself?

Reach for a state machine when at least two of the following are true, and skip it otherwise:

  1. The flow has three or more distinct steps the user moves between (not just three views — three states with different allowed actions).
  2. Some events are only legal in some states (retry only after failure, confirm only after review, pause only while playing).
  3. There is an async operation with real branching outcomes — 3DS, MFA, partial failure, retriable errors.
  4. Two or more independent booleans currently describe overlapping state (isLoading + isError + isSuccess is the classic tell).
  5. You have already shipped a bug that came from an impossible combination of flags.

A toggle, a modal, a controlled input, a fetch-once list — none of these need a machine. useState and useReducer handle them fine. The moment you write if (isLoading && !isError && !hasSubmitted), stop and reach for XState.

What actually changed in XState v5?

v5 is the first version that is genuinely small and TypeScript-first. The runtime is roughly 40% smaller than v4, the awful createMachine generic gymnastics are gone (replaced with setup({...}).createMachine({...})), and fromPromise / fromCallback actors killed the old service/interpreter distinction.

Concrete wins: types on events and context inferred everywhere without typegen, actors are just functions returning promises or callbacks, and the visualiser at stately.ai reads your machine JSON directly. If you tried XState in the v4 era and bounced off the ceremony, v5 is a genuinely different experience — try again.

For monitoring the async actors in production, wire in Sentry around onError transitions so failed createOrder invocations get traced with the exact machine state and context. That kills the "cannot reproduce" ticket in one afternoon.

Should I put my whole app in one XState machine?

No. Do not do this. It is the single most common mistake teams make after adopting XState.

Machines should be scoped to a flow, not an app. One machine per checkout, one per onboarding wizard, one per video player. Global state — cart, auth, feature flags — lives in Zustand or React Query. When machines need to talk, spawn child actors or use sendTo; don't merge everything into one 2000-line file. If your machine has more than about a dozen states, split it.

The takeaway

Stop faking state machines with four booleans and a useEffect. If your flow has real steps and illegal transitions, xstate v5 is the tool — the runtime is small, the TypeScript story is finally good, and the class of bugs it eliminates is worth the roughly one afternoon it takes to learn. Keep Zustand or React Query for the boring shared state. Use a machine where the logic actually is a machine.

FAQs

Is XState overkill for a simple form?

Yes. A single form with validation and a submit button is not a state machine — it is a controlled input plus a fetch. Use useState or a form library like React Hook Form. Reach for XState when the form has multiple steps and steps have distinct rules about what you can do next.

Can I use XState with Redux or Zustand in the same app?

Yes, and you probably should. XState handles flow logic, Zustand or Redux handles shared data. A checkout machine can read the cart from Zustand and write the resulting orderId back into it on success. Keep the responsibilities separated and neither library fights the other.

What is the difference between useReducer and XState?

useReducer gives you a reducer function — you still have to manually check the current state before every dispatch and hope you got the guards right. XState makes states explicit and rejects illegal events for you. XState also handles async invocations, hierarchical states, and parallel regions, which useReducer would need hundreds of lines to fake.

Does XState v5 work with React Server Components?

Machines themselves are plain JavaScript and run anywhere, but useMachine is a client hook — put it in components marked 'use client'. Long-running actors and step-based flows are inherently interactive, so this is the right boundary. For fetch-once server data, use RSC and skip the machine entirely.

How do I test an XState machine?

Test the machine in isolation with Vitest — import it, send events, assert on the resulting state and context. No React, no jsdom, no rendering. For the React integration, render the component with @testing-library/react and assert on visible output per state. Model-based testing via @xstate/test can generate paths automatically if the machine is large enough to warrant it.

H
Hiten

Senior Frontend Engineer & Architect. 15+ years building fast, accessible web platforms. More at hiten.dev