How to Use XState for Complex React Workflows

Model React workflows as state machines with XState. Create a machine, run it with useMachine, and render each state explicitly.

6 min read

XState is a state management library that models app logic as state machines. Instead of scattered booleans, you define the states a workflow can be in and the events that move between them. This tutorial builds a fetch workflow with idle, loading, success, and failure states.

Why explicit states beat booleans

The classic alternative is several booleans: isLoading, hasError, and isSuccess. Those booleans can all be true at the same time, producing impossible UI states. A machine replaces them with one current state, so loading and error can never both render. A machine also gives every transition a name, so the UI logic reads like a map instead of nested ifs.

Install XState

XState ships as a core package plus a React binding. Install both, since the machine logic comes from xstate and the hooks come from @xstate/react.

bashbash
npm install xstate @xstate/react

The core package provides createMachine and assign. The React package provides useMachine, which starts a machine for the lifetime of a component. XState v5 is the current major version, so older v4 tutorials use different imports.

Create a machine

A machine declares its initial state and the events each state accepts. Events move the machine from one state to another. Each state is a plain string key, and each event is an object with a type field.

App.jsxApp.jsx
import { createMachine } from "xstate";
 
const fetchMachine = createMachine({
  initial: "idle",
  states: {
    idle: { on: { FETCH: "loading" } },
    loading: {
      invoke: { src: "fetchData", onDone: "success", onError: "failure" },
    },
    success: { on: { RESET: "idle" } },
    failure: { on: { RETRY: "loading" } },
  },
});

The states object lists every possible state. The loading state invokes an actor named fetchData instead of waiting for a manually sent event, so the machine moves to success or failure on its own once the request settles. The machine also documents the workflow: a new developer can read the states object and see every transition without tracing event handlers.

Run it with useMachine

useMachine starts the machine and returns the current snapshot plus a send function. The component renders based on the snapshot, not on a pile of conditionals. The snapshot includes the current state value and any context.

App.jsxApp.jsx
import { useMachine } from "@xstate/react";
 
export function Fetcher() {
  const [state, send] = useMachine(fetchMachine);
  if (state.matches("idle")) return <button onClick={() => send({ type: "FETCH" })}>Load</button>;
  if (state.matches("loading")) return <p>Loading...</p>;
  if (state.matches("success")) return <button onClick={() => send({ type: "RESET" })}>Reload</button>;
  return <button onClick={() => send({ type: "RETRY" })}>Retry</button>;
}

Clicking Load sends a FETCH event and the machine moves to loading, which renders the loading text. The component can never be in two states at once, because the machine enforces exactly one current state. The send function is stable, so you can pass it down without causing extra renders.

Hold data in context

A machine can carry data in context and update it with assign. Events then update both the state and the data in one step.

App.jsxApp.jsx
import { createMachine, assign } from "xstate";
 
const counterMachine = createMachine({
  context: { count: 0 },
  on: {
    INC: { actions: assign({ count: ({ context }) => context.count + 1 }) },
  },
});

The context holds the count, and the INC event assigns a new value computed from the old one. The component reads it from the snapshot context. assign never mutates context; it returns a new value, matching React's immutable update rule.

Add async work

A state can invoke an async source, such as a fetch, and move on done or error. The fetch implementation is supplied with machine.provide and fromPromise, which keeps the machine reusable and the side effect testable.

App.jsxApp.jsx
const [state, send] = useMachine(
  fetchMachine.provide({
    actors: { fetchData: fromPromise(() => fetch("/api/data").then((res) => res.json())) },
  })
);

The invoke block in the machine references fetchData by name, and provide wires the real function in. This split is what makes the same machine easy to reuse and unit test. Because the fetch is injected, one machine can point at a real API in the app and a fake one in tests.

When XState is the right fit

XState earns its place when a workflow has real branching, like checkout, onboarding, or a player with many states. For simple counters and forms it is overkill, and plain state or a reducer is simpler.

Machines can also be drawn visually in Stately Studio, which helps when the workflow is hard to hold in your head. A broader comparison is in the state management decision guide, and the question of scope is in how to avoid global state when local state is enough.

Rune AI

Rune AI

Key Insights

  • Install xstate and @xstate/react together.
  • Define states and events with createMachine.
  • Run the machine with useMachine and send events.
  • Store workflow data in context with assign.
RunePowered by Rune AI

Frequently Asked Questions

What is a state machine in XState?

An object that lists the states a workflow can be in, the events that move between them, and optional context for data.

How does useMachine work?

It starts a machine for the lifetime of a component and returns a tuple of the current snapshot, a send function, and the actor ref.

Is XState v5 the current version?

Yes. XState v5 uses actors, createMachine, and assign. Older v4 examples use a different import path.

Conclusion

XState turns workflow logic into explicit states and events. Define a machine with createMachine, run it with useMachine, and render from the current snapshot instead of scattered booleans.