Development 7 min read 1,493 views

State Machines in UI Development: A Practical XState 5 Guide

When a state machine beats useState, and how to build one with XState 5 — actor syntax, a real fetch machine, React binding, testing, and when not to use one.

State Machines in UI Development: A Practical XState 5 Guide

Most UI bugs in the "impossible" category are the same bug: two booleans that should never both be true. isLoading and isError both set. A form that is submitting and editable at once. A modal that is closed but still holding data.

A state machine removes that category of bug by construction — not by being more careful, but by making the invalid combinations unrepresentable. This guide covers when that is worth doing and how it looks in XState 5.32 with @xstate/react 6.1.

The problem with boolean state

Here is state most React codebases contain somewhere:

const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [data, setData] = useState(null)

Four booleans is sixteen possible combinations. Perhaps four are valid. The other twelve are bugs waiting for the right race condition — a user clicking retry while a request is in flight, a response arriving after the component moved on.

You cannot test your way out of this reliably, because the invalid states are reachable by definition. The fix is to make them unreachable.

The same thing as a machine

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

export const dataMachine = setup({
  types: {
    context: {} as { data: User[] | null; error: string | null },
    events: {} as { type: 'FETCH' } | { type: 'RETRY' } | { type: 'CANCEL' },
  },
  actors: {
    fetchUsers: fromPromise(async () => {
      const res = await fetch('/api/users')
      if (!res.ok) throw new Error('Request failed')
      return res.json()
    }),
  },
}).createMachine({
  id: 'data',
  initial: 'idle',
  context: { data: null, error: null },
  states: {
    idle: {
      on: { FETCH: 'loading' },
    },
    loading: {
      invoke: {
        src: 'fetchUsers',
        onDone: {
          target: 'success',
          actions: assign({ data: ({ event }) => event.output, error: null }),
        },
        onError: {
          target: 'failure',
          actions: assign({ error: ({ event }) => String(event.error) }),
        },
      },
      on: { CANCEL: 'idle' },
    },
    success: {
      on: { FETCH: 'loading' },
    },
    failure: {
      on: { RETRY: 'loading' },
    },
  },
})

Four states, and only four. There is no way to be loading and failed simultaneously, because a machine is in exactly one state at a time. The twelve invalid combinations do not exist to be tested.

Notice something else: RETRY is only accepted in failure. Send it while loading and nothing happens — no guard clause, no early return. The machine simply has no transition for it.

What changed in XState 5

If you are reading older tutorials, the syntax will not match. The main differences:

XState 4XState 5
SetupcreateMachine + options objectsetup({...}).createMachine({...})
TypesGenerics, often awkwardDeclared in setup.types
Servicesservicesactors
Action arguments(context, event)({ context, event })
Promisesinvoke with a promisefromPromise()
React hookuseMachineuseMachine, actor-based

The practical upshot is that TypeScript inference is dramatically better. Declaring your events in setup gives you exhaustive checking — send an event the machine does not handle and it is a compile error, not a silent no-op.

Using it in React

import { useMachine } from '@xstate/react'
import { dataMachine } from './dataMachine'

export function UserList() {
  const [state, send] = useMachine(dataMachine)

  if (state.matches('idle')) {
    return <button onClick={() => send({ type: 'FETCH' })}>Load users</button>
  }

  if (state.matches('loading')) {
    return (
      <>
        <Spinner />
        <button onClick={() => send({ type: 'CANCEL' })}>Cancel</button>
      </>
    )
  }

  if (state.matches('failure')) {
    return (
      <div role="alert">
        <p>{state.context.error}</p>
        <button onClick={() => send({ type: 'RETRY' })}>Try again</button>
      </div>
    )
  }

  return <ul>{state.context.data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

The component became a rendering function of one value. There is no combination of flags to reason about, and adding a state later means adding a branch the compiler will point at.

Guards and context

Real flows need conditions. Guards decide whether a transition is allowed:

const checkoutMachine = setup({
  types: {
    context: {} as { items: Item[]; attempts: number },
    events: {} as { type: 'SUBMIT' } | { type: 'RETRY' },
  },
  guards: {
    hasItems: ({ context }) => context.items.length > 0,
    underRetryLimit: ({ context }) => context.attempts < 3,
  },
}).createMachine({
  initial: 'cart',
  context: { items: [], attempts: 0 },
  states: {
    cart: {
      on: {
        SUBMIT: { target: 'paying', guard: 'hasItems' },
      },
    },
    paying: {
      on: {
        RETRY: [
          { target: 'paying', guard: 'underRetryLimit',
            actions: assign({ attempts: ({ context }) => context.attempts + 1 }) },
          { target: 'failed' },
        ],
      },
    },
    failed: { type: 'final' },
  },
})

That retry array is worth reading closely: transitions are evaluated in order, and the first whose guard passes wins. Under three attempts it retries; otherwise it falls through to failed. The retry limit is part of the machine definition rather than an if buried in a handler.

Testing

Machines are unusually pleasant to test, because you can drive them without rendering anything:

import { createActor } from 'xstate'
import { describe, it, expect } from 'vitest'
import { dataMachine } from './dataMachine'

describe('dataMachine', () => {
  it('ignores RETRY while loading', () => {
    const actor = createActor(dataMachine).start()

    actor.send({ type: 'FETCH' })
    expect(actor.getSnapshot().value).toBe('loading')

    actor.send({ type: 'RETRY' })
    expect(actor.getSnapshot().value).toBe('loading') // unchanged
  })

  it('can only retry from failure', () => {
    const actor = createActor(dataMachine).start()
    expect(actor.getSnapshot().can({ type: 'RETRY' })).toBe(false)
  })
})

can() is the useful one. It answers "is this event valid right now?" without side effects — handy both in tests and for disabling buttons in the UI from the same source of truth.

When not to use a state machine

This is the part most articles leave out, and it matters more than the API.

  • Boolean toggles. A dropdown that is open or closed is a useState. Wrapping it in a machine is ceremony.
  • Server state. Caching, revalidation and deduplication are what TanStack Query exists for. Do not rebuild it as a machine.
  • Simple forms. React Hook Form covers validation and submission well. Reach for a machine when the form is a multi-step flow with branching.
  • When the team will not maintain it. A machine nobody else understands is worse than flags everybody does. Introduce it on one complex flow and see how the team finds it.

The honest test: count the invalid combinations your current state allows. If the answer is zero or one, you do not need a machine. If it is a dozen, you already have a machine — an implicit one, spread across handlers.

Where machines earn their keep

Multi-step checkout and onboarding. Media players with buffering, seeking and error recovery. Anything with retries and cancellation. Drag-and-drop. Authentication flows with 2FA challenges and recovery paths. Long-running processes with progress and rollback.

The pattern is the same across all of them: several genuinely distinct modes, transitions that are only valid from certain modes, and a real cost when an invalid combination reaches a user.

Frequently asked questions

When should I use a state machine instead of useState?

When your component has several genuinely distinct modes and some transitions are only valid from some of them. A useful test: count the combinations your booleans allow that should never happen. Zero or one means useState is fine; a dozen means you already have an implicit machine.

What changed between XState 4 and XState 5?

Machines are now defined with setup({...}).createMachine({...}), types are declared in setup.types rather than through generics, services became actors, promises use fromPromise(), and action arguments are a single object. TypeScript inference is much stronger as a result.

Is XState overkill for a small app?

Often, yes. It earns its cost on flows with several modes and invalid transitions — checkout, onboarding, media playback, retry and cancellation. For toggles and simple forms it is ceremony without benefit.

Does XState replace Redux or Zustand?

They solve different problems. Redux and Zustand hold shared application state; XState models how one thing moves between modes. Many applications use both — a store for global data, machines for complex flows.

Should I use XState for data fetching?

Usually not on its own. TanStack Query already handles caching, revalidation and deduplication. Use a machine when the fetch is part of a larger flow with cancellation, retry limits or branching afterwards.

How do I test an XState machine?

Create an actor with createActor(), send events, and assert on getSnapshot().value and context. No rendering required, so the tests are fast. can() lets you assert that an invalid event is genuinely rejected.

How do I use XState with React?

Install @xstate/react and call useMachine(machine), which returns the current state and a send function. Branch your render on state.matches(...) and read data from state.context.

Are state machines and statecharts the same thing?

A statechart is a finite state machine plus hierarchy, parallel states and history. XState implements statecharts, so you can start with flat states and add nesting only when a flow genuinely needs it.

Share this article:
ES
Written by

Edrees Salih

Full-stack software engineer with 9 years of experience. Passionate about building scalable solutions and sharing knowledge with the developer community.

View Profile

Comments (0)

Leave a Comment

Your email will not be published.

No comments yet. Be the first to share your thoughts!

Related Articles

Related Articles

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.