Skip to content
C Codeloom
React

React State Machines with XState

Learn how XState brings finite state machines to React, eliminating impossible UI states with explicit transitions, guards, and a predictable mental model for complex flows.

·4 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • What a finite state machine is
  • Why XState beats boolean flags
  • How to wire XState into React
  • Guards, actions, and context
  • Where state machines shine and where they overkill

Prerequisites

  • Comfortable with React hooks

State machines turn fuzzy UI logic into a small, explicit set of states and transitions. XState is the most popular library for using them in React. This tutorial walks through the mental model, a working example, and the tradeoffs.

What and Why

A finite state machine has a known set of states, a known set of events, and rules that say which events move you between states. There is exactly one current state at any moment. That single property is what makes machines so useful for UI work.

Most React bugs come from boolean soup. You end up with isLoading, isError, hasData, and isRetrying and the combinations that should be impossible end up rendering anyway. A state machine makes impossible states unrepresentable because you can only be in one named state at a time.

XState gives you a declarative way to write that machine, a React hook to wire it up, and a visualizer to inspect it. You stop guessing about UI behavior and start designing it.

Mental Model

Think of a traffic light. It is red, yellow, or green, never two at once. A timer event moves it forward in a fixed cycle. That is a state machine. Your data fetcher is the same shape: idle, loading, success, failure, with events like FETCH, RESOLVE, REJECT.

XState adds two upgrades. Context holds extended data attached to the machine, like the fetched payload. Guards are predicates on transitions, so an event only fires if a condition is met. Actions are side effects triggered when transitions happen.

Hands-on Example

Here is a small fetch machine and its React integration.

   FETCH
idle ------> loading
              |
      RESOLVE | REJECT
              v
          success / failure
              |
            RETRY
              v
           loading
States and transitions for a fetch machine
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';

const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: { data: null, error: null },
  states: {
    idle: { on: { FETCH: 'loading' } },
    loading: {
      invoke: {
        src: 'fetchUser',
        onDone: { target: 'success', actions: assign({ data: (_, e) => e.data }) },
        onError: { target: 'failure', actions: assign({ error: (_, e) => e.data }) },
      },
    },
    success: { on: { FETCH: 'loading' } },
    failure: { on: { RETRY: 'loading' } },
  },
});

function User() {
  const [state, send] = useMachine(fetchMachine, {
    services: { fetchUser: () => fetch('/api/me').then((r) => r.json()) },
  });

  if (state.matches('idle')) return <button onClick={() => send('FETCH')}>Load</button>;
  if (state.matches('loading')) return <p>Loading...</p>;
  if (state.matches('failure')) return <button onClick={() => send('RETRY')}>Retry</button>;
  return <pre>{JSON.stringify(state.context.data, null, 2)}</pre>;
}

The rendering is driven by state.matches. There is no way to render a loading spinner next to an error message because the machine cannot be in both states at once.

Common Pitfalls

Putting everything in a machine is the first mistake. Forms with five fields do not need a machine. Reach for XState when transitions matter more than the values.

Overloading context with data that belongs in React Query or props is another trap. The machine should own state shape, not the entire data layer.

Forgetting that useMachine recreates the machine on each call is a subtle issue. Define the machine outside the component or use useMemo so identity stays stable.

Finally, do not skip the visualizer. Drawing the chart is half the value, and you miss it if you only read the JSON.

Best Practices

Keep machines small and composable. If a chart grows past ten states, split it into child machines or use hierarchical states. Name events as verbs and states as nouns to keep reading natural.

Push side effects into actions and services rather than firing them from React. That keeps the machine portable and testable. Write unit tests by sending events and asserting on state, no rendering required.

Use the inspector during development. Watching transitions live catches missing edges faster than reading code.

Wrap-up

XState gives React apps a precise vocabulary for behavior. Once you think in states and events, entire categories of bugs disappear. Start with one tangled component, model it, and ship the machine. The clarity is addictive.