writing/tutorial/2026/07
TutorialJul 14, 2026·28 min read

XState v5 with React and TypeScript: Building Bulletproof State Machines

Learn how to model complex UI and async logic with XState v5 in a Next.js app. This hands-on tutorial covers the setup() API, typed context and events, promise actors, guards, retries and cancellation, spawned child actors, global state with createActorContext, persistence, and testing.

Every React developer has written this code:

const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)

Four booleans mean sixteen possible combinations, and only four of them are legal. What happens when isLoading and isSuccess are both true? Nothing good — but nothing in your code prevents it either. This is the class of bug that produces double-charged customers, stuck spinners, and forms that submit twice.

XState v5 solves this by making illegal states unrepresentable. Instead of a bag of booleans, you declare a finite set of states and the exact transitions between them. If a transition is not declared, it cannot happen. The machine simply ignores the event.

In this tutorial you will build a real, production-shaped feature — a multi-step checkout flow with async payment, retries, cancellation, and parallel file uploads — using XState v5, React, and TypeScript in a Next.js app. By the end you will understand actors, the setup() API, promise-based invocations, guards, spawning, persistence, and testing.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • A working knowledge of React hooks and TypeScript generics
  • Familiarity with async/await and promises
  • A code editor (VS Code with the Stately extension is ideal)
  • About 60 minutes of focused time

You do not need any prior XState experience. If you used XState v4, be aware that v5 is a substantial redesign — the old Machine(), withContext(), and services APIs are gone.

What You Will Build

A checkout machine that:

  1. Collects cart details (idle state)
  2. Validates the cart before allowing submission (guards)
  3. Calls a payment API (promise actor)
  4. Retries up to three times on network failure with exponential backoff (delayed transitions)
  5. Can be cancelled mid-flight by the user (actor teardown)
  6. Uploads receipt attachments in parallel (spawned child actors)
  7. Survives a page reload (persistence)

Along the way you will see why the machine, not the component, becomes the source of truth.

Step 1: Project Setup

Create a Next.js app and add the XState packages:

npx create-next-app@latest xstate-checkout --typescript --app --tailwind
cd xstate-checkout
npm install xstate @xstate/react
npm install -D @statelyai/inspect

Three packages, three jobs:

  • xstate — the framework-agnostic core (machines, actors, interpreters)
  • @xstate/react — React bindings (useMachine, useSelector, createActorContext)
  • @statelyai/inspect — a live visual debugger, dev-only

Confirm the versions are v5 (anything 5.x):

npm ls xstate

Step 2: Your First Machine with setup()

The single biggest change in v5 is the setup() function. It is where you register your types, actors, actions, guards, and delays before defining the machine. Everything registered in setup() becomes fully type-safe inside the machine definition — no more typegen step, no more generated files.

Create src/machines/checkout.machine.ts:

import { setup, assign } from 'xstate'
 
// The data the machine carries across states
interface CheckoutContext {
  cartTotal: number
  email: string
  orderId: string | null
  errorMessage: string | null
  retries: number
}
 
// Every event the machine can possibly receive
type CheckoutEvent =
  | { type: 'CART.UPDATE'; total: number }
  | { type: 'EMAIL.CHANGE'; email: string }
  | { type: 'SUBMIT' }
  | { type: 'CANCEL' }
  | { type: 'RETRY' }
  | { type: 'RESET' }
 
export const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as CheckoutEvent,
  },
}).createMachine({
  id: 'checkout',
  initial: 'editing',
  context: {
    cartTotal: 0,
    email: '',
    orderId: null,
    errorMessage: null,
    retries: 0,
  },
  states: {
    editing: {
      on: {
        'CART.UPDATE': {
          actions: assign({ cartTotal: ({ event }) => event.total }),
        },
        'EMAIL.CHANGE': {
          actions: assign({ email: ({ event }) => event.email }),
        },
        SUBMIT: { target: 'submitting' },
      },
    },
    submitting: {},
    success: { type: 'final' },
    failure: {},
  },
})

Notice the shape of an action: ({ context, event }) => value. In v5 every callback receives a single object argument, not positional arguments. That one change is what makes the whole API inferrable.

Also notice what is not there: no isLoading, no isError. The state name is the loading flag. A machine cannot be in editing and submitting at the same time — the type system and the runtime both forbid it.

Step 3: Wiring the Machine into React

The useMachine hook starts an actor when the component mounts and stops it when the component unmounts. Create src/components/Checkout.tsx:

'use client'
 
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
 
export function Checkout() {
  const [snapshot, send] = useMachine(checkoutMachine)
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        send({ type: 'SUBMIT' })
      }}
    >
      <input
        type="email"
        value={snapshot.context.email}
        onChange={(e) => send({ type: 'EMAIL.CHANGE', email: e.target.value })}
      />
 
      <button type="submit" disabled={!snapshot.can({ type: 'SUBMIT' })}>
        {snapshot.matches('submitting') ? 'Processing…' : 'Pay now'}
      </button>
 
      {snapshot.matches('failure') && <p role="alert">{snapshot.context.errorMessage}</p>}
    </form>
  )
}

Two methods carry most of the weight here.

snapshot.matches('submitting') asks a question about the current state. Because the state names came from setup(), TypeScript autocompletes them and rejects typos at compile time.

snapshot.can(...) is the one that eliminates an entire bug family. It asks the machine: if I sent this event right now, would anything happen? The machine already knows that SUBMIT is only handled in editing, so the button disables itself in every other state. You never write disabled={isLoading || isSubmitting || !isValid} again — and you can never forget one of those conditions.

Step 4: Async Work with Promise Actors

In XState v5, everything is an actor: machines, promises, callbacks, observables. To call your payment API, wrap it with fromPromise and register it in setup().

Add the API client and the actor:

import { setup, assign, fromPromise } from 'xstate'
 
interface PaymentInput {
  email: string
  amount: number
}
 
interface PaymentResult {
  orderId: string
}
 
async function chargeCard(input: PaymentInput): Promise<PaymentResult> {
  const res = await fetch('/api/payments', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  })
 
  if (!res.ok) {
    throw new Error(`Payment failed with status ${res.status}`)
  }
 
  return res.json()
}
 
export const checkoutMachine = setup({
  types: {
    context: {} as CheckoutContext,
    events: {} as CheckoutEvent,
  },
  actors: {
    // fromPromise turns any async function into an actor
    chargeCard: fromPromise<PaymentResult, PaymentInput>(({ input }) => chargeCard(input)),
  },
}).createMachine({
  // ...
})

The two generic parameters are the output type and the input type, in that order. Now invoke it from the submitting state:

submitting: {
  invoke: {
    src: 'chargeCard',
    // input maps machine context onto the actor's input
    input: ({ context }) => ({
      email: context.email,
      amount: context.cartTotal,
    }),
    onDone: {
      target: 'success',
      actions: assign({
        orderId: ({ event }) => event.output.orderId,
      }),
    },
    onError: {
      target: 'failure',
      actions: assign({
        errorMessage: ({ event }) => (event.error as Error).message,
      }),
    },
  },
  on: {
    CANCEL: { target: 'editing' },
  },
},

Read that carefully, because it is the heart of XState. The invoke block says: while this state is active, run this actor. The input function is the only bridge from machine context to the actor — the actor cannot reach into your context, which keeps it independently testable.

On resolve, the promise's value arrives as event.output. On reject, the error arrives as event.error. TypeScript knows both types from your fromPromise generics.

And the CANCEL handler? When the machine leaves submitting, XState automatically stops the invoked actor. There is no useEffect cleanup to write, no AbortController to remember, no "component unmounted" warning. Exiting the state tears down everything the state owned. This is the property that makes machines so much safer than ad-hoc hooks: cleanup is structural, not something you must remember.

Step 5: Guards — Refusing Invalid Transitions

Right now a user can submit an empty cart. Guards are pure predicate functions that decide whether a transition is allowed. Register them in setup():

export const checkoutMachine = setup({
  types: { /* ... */ },
  actors: { /* ... */ },
  guards: {
    isCartValid: ({ context }) =>
      context.cartTotal > 0 && context.email.includes('@'),
    canRetry: ({ context }) => context.retries < 3,
  },
}).createMachine({
  // ...
})

Then attach a guard to the transition:

editing: {
  on: {
    SUBMIT: {
      target: 'submitting',
      guard: 'isCartValid',
    },
  },
},

The payoff is immediate: snapshot.can({ type: 'SUBMIT' }) now evaluates the guard too. Your Pay button automatically greys out for an invalid cart, with zero extra component code. Validation logic lives in exactly one place, and the UI reads it rather than duplicating it.

Step 6: Retries with Exponential Backoff

Network calls fail. A robust checkout retries transient failures automatically, but bounds the attempts. Model this with a retrying state and a delayed transition (after).

Register a dynamic delay in setup():

delays: {
  // 1s, 2s, 4s — exponential backoff based on attempt count
  backoff: ({ context }) => 2 ** context.retries * 1000,
},

Then restructure the failure path:

failure: {
  // Automatically retry if we have attempts left
  always: {
    target: 'retrying',
    guard: 'canRetry',
  },
  on: {
    RESET: { target: 'editing', actions: 'clearError' },
  },
},
 
retrying: {
  entry: assign({ retries: ({ context }) => context.retries + 1 }),
  after: {
    // Wait for the computed backoff, then try again
    backoff: { target: 'submitting' },
  },
  on: {
    CANCEL: { target: 'editing' },
  },
},

Three ideas are doing real work here.

An always transition (also called an eventless transition) fires the instant the machine enters the state, if its guard passes. So failure immediately forwards to retrying while attempts remain, and stays put — waiting for a human RESET — once canRetry returns false.

The after block schedules a transition on a timer. Because backoff is a registered delay function, it recomputes on each entry: 1 second, then 2, then 4.

And the timer is owned by the state. If the user hits CANCEL during the backoff wait, the machine leaves retrying and the pending timer is cancelled with it. No stray setTimeout fires against an unmounted component two seconds later. Compare that to what this logic looks like with useEffect and a ref holding a timeout ID.

Step 7: Spawning Child Actors for Parallel Uploads

Invoked actors live and die with a state. But sometimes you need a dynamic number of long-lived actors — one per uploaded file, each with its own independent progress. That is what spawning is for.

First, a tiny upload machine. Each file gets its own instance:

import { setup, fromPromise, assign } from 'xstate'
 
export const uploadMachine = setup({
  types: {
    context: {} as { file: File; progress: number; url: string | null },
    input: {} as { file: File },
  },
  actors: {
    uploadFile: fromPromise<string, { file: File }>(async ({ input }) => {
      const body = new FormData()
      body.append('file', input.file)
      const res = await fetch('/api/upload', { method: 'POST', body })
      const data = await res.json()
      return data.url as string
    }),
  },
}).createMachine({
  id: 'upload',
  // input flows into the initial context — this is how v5 replaces withContext()
  context: ({ input }) => ({ file: input.file, progress: 0, url: null }),
  initial: 'uploading',
  states: {
    uploading: {
      invoke: {
        src: 'uploadFile',
        input: ({ context }) => ({ file: context.file }),
        onDone: {
          target: 'done',
          actions: assign({ url: ({ event }) => event.output }),
        },
        onError: { target: 'failed' },
      },
    },
    done: { type: 'final' },
    failed: {
      on: { RETRY: { target: 'uploading' } },
    },
  },
})

Now spawn one per file from the parent machine, using enqueueActions:

import { enqueueActions } from 'xstate'
 
// In the parent machine's setup():
actors: {
  chargeCard: /* ... */,
  uploadMachine,
},
 
// In the parent machine's states:
on: {
  'FILES.ADD': {
    actions: enqueueActions(({ enqueue, event }) => {
      for (const file of event.files) {
        enqueue.spawnChild('uploadMachine', {
          id: `upload-${file.name}`,
          input: { file },
          syncSnapshot: true,
        })
      }
    }),
  },
},

Each spawned child is a fully independent actor with its own state, its own retry logic, and its own lifecycle. One upload failing does not touch the others. And because uploadMachine is a standalone machine, you can unit test it on its own without ever constructing a checkout.

Step 8: Global State with createActorContext

useMachine scopes the actor to one component. When several distant components need the same machine — a header cart badge, a sidebar summary, the checkout form — hoist it with createActorContext.

// src/machines/checkout.context.tsx
'use client'
 
import { createActorContext } from '@xstate/react'
import { checkoutMachine } from './checkout.machine'
 
export const CheckoutMachineContext = createActorContext(checkoutMachine)

Wrap your tree once:

export function Providers({ children }: { children: React.ReactNode }) {
  return <CheckoutMachineContext.Provider>{children}</CheckoutMachineContext.Provider>
}

Then read it anywhere — and this is where performance matters:

export function CartBadge() {
  // useSelector subscribes to ONE slice; this component re-renders only
  // when cartTotal actually changes, not on every machine transition.
  const total = CheckoutMachineContext.useSelector((s) => s.context.cartTotal)
  const send = CheckoutMachineContext.useActorRef().send
 
  return <button onClick={() => send({ type: 'SUBMIT' })}>Cart: {total} TND</button>
}

Reach for useSelector rather than pulling the whole snapshot. A machine in a retry loop transitions several times per second; a component that selects only cartTotal sleeps through all of it. This is the same principle behind Zustand and Jotai selectors, and it is the difference between a smooth app and a janky one.

Step 9: Persisting State Across Reloads

A user refreshes mid-checkout. With useState, the flow is gone. XState actors serialize their entire snapshot — current state, context, and child actors included.

'use client'
 
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
 
const STORAGE_KEY = 'checkout-snapshot'
 
function loadSnapshot() {
  if (typeof window === 'undefined') return undefined
  const raw = window.localStorage.getItem(STORAGE_KEY)
  return raw ? JSON.parse(raw) : undefined
}
 
export function PersistentCheckout() {
  const [snapshot, send, actorRef] = useMachine(checkoutMachine, {
    snapshot: loadSnapshot(),
  })
 
  useEffect(() => {
    const sub = actorRef.subscribe(() => {
      const persisted = actorRef.getPersistedSnapshot()
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
    })
    return () => sub.unsubscribe()
  }, [actorRef])
 
  // ...
}

Two things are worth flagging.

getPersistedSnapshot() is not the same object as snapshot. It is a serializable representation stripped of functions and live references, designed to survive JSON.stringify — that distinction trips up almost everyone the first time.

And restoring is not the same as replaying. The machine resumes in the persisted state; it does not re-run the transitions that got it there. So a snapshot saved during submitting will restore into submitting and immediately re-invoke chargeCard. For a payment flow that is the wrong behaviour — you would double-charge. Persist only from safe states, or add an entry guard that redirects a restored submitting state to a "verify order status" state that asks your API whether the charge already went through.

Step 10: Visual Debugging with the Stately Inspector

This is the feature that sells XState to skeptical teammates. Pass an inspect option and watch your machine run as a live diagram.

'use client'
 
import { createBrowserInspector } from '@statelyai/inspect'
 
const inspector =
  process.env.NODE_ENV === 'development' ? createBrowserInspector() : undefined
 
export function Checkout() {
  const [snapshot, send] = useMachine(checkoutMachine, {
    inspect: inspector?.inspect,
  })
  // ...
}

Run the app and a browser tab opens with your state chart. Every event, guard evaluation, and context change animates in real time. Debugging a stuck flow stops being an exercise in console.log archaeology — you literally watch which transition did or did not fire, and which guard blocked it.

Keep the inspector behind the NODE_ENV check so it never ships to production.

Testing Your Machine

Because a machine is a pure, framework-agnostic object, you can test all of your logic without rendering a single React component. Here is a test with Vitest:

import { describe, it, expect, vi } from 'vitest'
import { createActor } from 'xstate'
import { checkoutMachine } from './checkout.machine'
 
describe('checkoutMachine', () => {
  it('refuses to submit an invalid cart', () => {
    const actor = createActor(checkoutMachine).start()
 
    actor.send({ type: 'SUBMIT' })
 
    // The guard blocked it — we never left editing
    expect(actor.getSnapshot().matches('editing')).toBe(true)
  })
 
  it('charges the card and reaches success', async () => {
    // provide() swaps a real actor for a fake, with no mocking library
    const testMachine = checkoutMachine.provide({
      actors: {
        chargeCard: fromPromise(async () => ({ orderId: 'ord_123' })),
      },
    })
 
    const actor = createActor(testMachine).start()
    actor.send({ type: 'EMAIL.CHANGE', email: 'a@b.com' })
    actor.send({ type: 'CART.UPDATE', total: 100 })
    actor.send({ type: 'SUBMIT' })
 
    // waitFor resolves once the predicate passes
    const finalState = await waitFor(actor, (s) => s.matches('success'))
 
    expect(finalState.context.orderId).toBe('ord_123')
  })
})

machine.provide() is the testing superpower. It returns a new machine with implementations swapped out, leaving the state chart untouched. No vi.mock, no module interception, no dependency injection framework. Your payment actor becomes a two-line stub, and the machine under test is otherwise the exact one that runs in production.

Troubleshooting

"Events are being ignored and nothing happens." This is XState working correctly. An event with no matching transition in the current state is a no-op by design. Open the inspector and check which state you are actually in — usually you sent SUBMIT while already in submitting.

"My action fires but the context does not update." You almost certainly mutated context directly. assign must return a new value; context.retries++ inside an action does nothing useful. Return { retries: context.retries + 1 } instead.

"TypeScript cannot infer my event types." Your event union must be a discriminated union keyed on type, and it must be registered in setup({ types: { events: {} as MyEvents } }). The {} as T cast is deliberate — you are supplying a type, not a value.

"Invoked actor runs twice in development." That is React 18 Strict Mode double-mounting components. It is expected and does not happen in production, but it is a genuine warning sign for non-idempotent side effects like payments. Make your API endpoint idempotent with a request key.

"Persisted snapshot fails to restore." You probably stored snapshot instead of actorRef.getPersistedSnapshot(). Only the latter is serializable.

When Not to Use XState

Honest advice: XState is not free. It adds a concept load that a simple app does not need.

Skip it for boolean toggles, simple forms, and plain server-data fetching — useState handles the first two and TanStack Query handles the third far better than a machine would. Reach for XState when the logic has genuine modes that behave differently, when transitions between them have rules, and when getting those rules wrong costs real money or data: checkout flows, multi-step wizards, media players, drag-and-drop, onboarding, and agent orchestration.

A good heuristic — if you have already drawn the flow on a whiteboard as boxes and arrows, XState lets you ship the whiteboard.

Next Steps

  • Explore @xstate/store, a much lighter alternative for simple shared state without the state-chart machinery
  • Model parallel states for independent concerns that run at the same time, such as a media player tracking playback and volume separately
  • Use fromCallback actors to wrap WebSocket connections and event emitters
  • Try the Stately Studio visual editor, which round-trips your machine between code and diagram
  • Compare this approach with our tutorials on Zustand for Next.js state management and Jotai atomic state
  • Apply the actor model to AI agents with our LangGraph.js stateful agents guide

Conclusion

You built a checkout flow that cannot enter an impossible state — because the impossible states do not exist in the model. Along the way you covered the setup() API and its type inference, promise actors with fromPromise, guards that automatically drive your disabled buttons, retries with exponential backoff and structural cleanup, spawned child actors for parallel work, createActorContext with fine-grained selectors, and snapshot persistence.

The deeper shift is where the logic lives. In the useState version, your business rules are scattered across event handlers, effects, and JSX conditionals — you can never see them all at once. In the XState version they live in one object you can read top to bottom, visualize as a diagram, test without a DOM, and hand to a designer who will actually understand it.

Booleans describe what your UI is. State machines describe what it is allowed to become. For anything where correctness matters, that second question is the one worth answering.