| |

React 116 ⚛️ State Machines with XState in React

⏱️ Estimated read: 14 min | |

A React component that manages complex interaction logic with useState and useEffect is, in the formal sense, an infinite state machine. Any combination of boolean flags and nullable values can occur, including combinations that should be impossible. XState replaces that open-ended model with a finite state machine: a declared set of states, a declared set of events, and a transition table that specifies exactly which events are legal in which states . The React integration, @xstate/react, exposes hooks that connect the machine to the component tree without leaking the machine’s internals into the rendering layer.

This chapter covers XState v5 and its React bindings. It begins with the machine definition—states, events, transitions, and context—and the useMachine hook that runs a machine within a component. It then explains the actor model that underlies XState and the useSelector hook that prevents unnecessary re-renders. The final sections address global state with createActorContext, the decision of when a state machine is worth the dependency, and the common mistakes that come from treating XState as a drop-in replacement for useReducer.


Key point: A state machine does not make state management easier. It makes illegal states impossible to represent. The value is in the constraints, not the convenience.


Why state machines exist in React

The infinite state problem. A component with three boolean flags has eight possible combinations, but only three or four are meaningful. The other four are bugs waiting to happen: a loading flag that is true while error is also set, a data value that exists in an idle state. useReducer with a discriminated union can restrict the combinations, but the restriction is enforced by convention and TypeScript, not by a runtime engine that rejects illegal transitions . XState enforces the transition table at runtime: an event that has no transition in the current state is silently ignored, not executed.

The orchestration problem. Async logic in React is scattered across useEffect calls, cleanup functions, and cancellation flags. A fetch that starts in one state and completes in another must check whether the component is still mounted, whether the request was superseded, and whether an error occurred in between. XState moves that orchestration into the machine: invoke declares an async actor that runs while a state is active, and the machine handles the success, error, and cancellation paths declaratively . The component renders the state; the machine manages the lifecycle.

The clarity problem. A complex interaction—a multi-step form, a media player, an authentication flow—has a small number of meaningful states and a small number of events. Writing that logic as nested conditionals obscures the structure. A state machine makes the structure explicit: the states are the top-level branches, and the transitions are the edges. The Stately visual editor can render the machine as a diagram, which is useful for design review and debugging .

The testing problem. A machine is a pure function from (state, event) to (next state, actions). It can be tested without rendering a component, without mocking React, and without simulating user interaction. The createActor function from XState runs the machine in isolation, and the test sends events and asserts on the resulting state . The component test, if it exists at all, only verifies that the correct UI is rendered for each state.


a. Defining a machine and running it with useMachine

The createMachine function takes a configuration object and returns a machine. The configuration declares the initial state, the context (extended state), and the states with their transitions. In XState v5, the setup function is the recommended way to define machines with typed context and events.

import { setup, assign } from 'xstate';

// A toggle machine with a count of how many times it has been toggled.
const toggleMachine = setup({
  types: {
    context: {} as { count: number },
    events: {} as { type: 'TOGGLE' },
  },
  actions: {
    increment: assign({
      count: ({ context }) => context.count + 1,
    }),
  },
}).createMachine({
  id: 'toggle',
  initial: 'inactive',
  context: { count: 0 },
  states: {
    inactive: {
      on: {
        TOGGLE: {
          target: 'active',
          actions: 'increment',
        },
      },
    },
    active: {
      on: {
        TOGGLE: 'inactive',
      },
    },
  },
});

The useMachine hook from @xstate/react creates an actor from the machine, starts it, and subscribes the component to its state changes. It returns a tuple of the current snapshot and the send function . The snapshot.matches method checks the current state by name, and snapshot.context holds the extended state.

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

function Toggle() {
  const [snapshot, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send({ type: 'TOGGLE' })}>
      {snapshot.matches('inactive') ? 'Off' : 'On'}
      {' '}({snapshot.context.count} toggles)
    </button>
  );
}

The matches method is the correct way to check state, not snapshot.value === 'inactive'. When a machine has hierarchical or parallel states, snapshot.value is an object, and matches handles the nested comparison . The send function accepts either an event object or a string shorthand, but the object form is the typed and explicit one.


b. The actor model and useSelector

XState v5 is built on the actor model. A machine is a specification; an actor is a running instance of that specification. The useMachine hook creates an actor and subscribes the component to every state change. That subscription is the source of a performance problem: a component that only cares about snapshot.context.count will re-render when any part of the machine’s state changes, including transitions that do not affect the count.

useSelector solves this by subscribing to a derived value. The selector function receives the snapshot and returns the part the component cares about. The component re-renders only when the selected value changes, as determined by the optional comparison function .

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

function ToggleCount() {
  const [snapshot, send] = useMachine(toggleMachine);

  // Select only the count. The component does not re-render
  // when the state changes but the count stays the same.
  const count = useSelector(
    // In v5, useMachine returns the actor ref as the third element.
    // The selector is applied to the actor, not the snapshot.
    (state) => state.context.count
  );

  return <span>{count}</span>;
}

The exact form of useSelector depends on the hook used to create the actor. With useActorRef, the actor ref is passed directly to useSelector. With useMachine, the actor ref is the third element of the returned tuple in XState v5 . The selector pattern is the recommended approach for any machine whose full state changes more often than the component’s rendered output.


c. Global state with createActorContext

For a machine that must be shared across components, XState provides createActorContext. It wraps the machine in a React context, provides a Provider component, and exposes hooks for selecting state and sending events. The actor is created once at the provider level, and every consuming component subscribes through the context .

import { createActorContext } from '@xstate/react';
import { setup, assign } from 'xstate';

const authMachine = setup({
  types: {
    context: {} as { user: { name: string } | null },
    events: {} as
      | { type: 'LOGIN'; user: { name: string } }
      | { type: 'LOGOUT' },
  },
}).createMachine({
  id: 'auth',
  initial: 'loggedOut',
  context: { user: null },
  states: {
    loggedOut: {
      on: {
        LOGIN: {
          target: 'loggedIn',
          actions: assign({ user: ({ event }) => event.user }),
        },
      },
    },
    loggedIn: {
      on: {
        LOGOUT: {
          target: 'loggedOut',
          actions: assign({ user: null }),
        },
      },
    },
  },
});

const AuthContext = createActorContext(authMachine);

function App() {
  return (
    <AuthContext.Provider>
      <Profile />
      <LoginButton />
    </AuthContext.Provider>
  );
}

function Profile() {
  const user = AuthContext.useSelector((state) => state.context.user);
  if (!user) return <p>Not logged in</p>;
  return <p>Welcome, {user.name}</p>;
}

function LoginButton() {
  const actorRef = AuthContext.useActorRef();
  const isLoggedIn = AuthContext.useSelector((state) =>
    state.matches('loggedIn')
  );

  return (
    <button
      onClick={() =>
        isLoggedIn
          ? actorRef.send({ type: 'LOGOUT' })
          : actorRef.send({ type: 'LOGIN', user: { name: 'Alice' } })
      }
    >
      {isLoggedIn ? 'Logout' : 'Login'}
    </button>
  );
}

The createActorContext pattern is the recommended replacement for the older useInterpret plus manual context provider approach. It handles the actor lifecycle, the context provider, and the selector-based subscriptions in a single abstraction . The provider should be placed as high in the tree as the machine’s state needs to be accessible.


Complete Example Session

// ============================================
// PART 1: THE MACHINE DEFINITION
// ============================================
// A data-fetching machine with idle, loading, success, error.

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

const fetchUser = fromPromise(async ({ input }: { input: { id: string } }) => {
  const res = await fetch(`/api/users/${input.id}`);
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json() as Promise<{ name: string }>;
});

const userMachine = setup({
  types: {
    context: {} as { user: { name: string } | null; error: string | null },
    events: {} as { type: 'FETCH'; id: string } | { type: 'RETRY' },
  },
  actors: { fetchUser },
}).createMachine({
  id: 'user',
  initial: 'idle',
  context: { user: null, error: null },
  states: {
    idle: {
      on: { FETCH: 'loading' },
    },
    loading: {
      invoke: {
        src: 'fetchUser',
        input: ({ event }) => ({ id: (event as any).id }),
        onDone: {
          target: 'success',
          actions: assign({ user: ({ event }) => event.output }),
        },
        onError: {
          target: 'error',
          actions: assign({ error: ({ event }) => String(event.error) }),
        },
      },
    },
    success: {
      on: { FETCH: 'loading' },
    },
    error: {
      on: { RETRY: 'loading' },
    },
  },
});

// ============================================
// PART 2: THE COMPONENT WITH useMachine
// ============================================
// The component renders the state and sends events.

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

function UserProfile() {
  const [snapshot, send] = useMachine(userMachine);

  if (snapshot.matches('idle')) {
    return <button onClick={() => send({ type: 'FETCH', id: '1' })}>Load</button>;
  }

  if (snapshot.matches('loading')) {
    return <p>Loading...</p>;
  }

  if (snapshot.matches('error')) {
    return (
      <div>
        <p>Error: {snapshot.context.error}</p>
        <button onClick={() => send({ type: 'RETRY' })}>Retry</button>
      </div>
    );
  }

  return <p>Welcome, {snapshot.context.user?.name}</p>;
}

// ============================================
// PART 3: THE SELECTOR OPTIMIZATION
// ============================================
// Only re-render when the user name changes.

function UserName() {
  const [snapshot] = useMachine(userMachine);
  // In v5, the actor ref is the third element.
  // useSelector subscribes to the selected slice.
  return null; // see the useSelector example in section b
}

// ============================================
// PART 4: THE GLOBAL CONTEXT
// ============================================
// createActorContext shares the machine across components.

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

const UserContext = createActorContext(userMachine);

function App() {
  return (
    <UserContext.Provider>
      <Toolbar />
      <Profile />
    </UserContext.Provider>
  );
}

function Toolbar() {
  const actorRef = UserContext.useActorRef();
  return (
    <button onClick={() => actorRef.send({ type: 'FETCH', id: '1' })}>
      Load User
    </button>
  );
}

function Profile() {
  const user = UserContext.useSelector((state) => state.context.user);
  return user ? <p>{user.name}</p> : null;
}

// ============================================
// PART 5: THE TEST
// ============================================
// The machine is testable without React.

import { createActor } from 'xstate';

function testMachine() {
  const actor = createActor(userMachine);
  actor.start();

  // Initially idle.
  assert(actor.getSnapshot().matches('idle'));

  // Send FETCH, state becomes loading.
  actor.send({ type: 'FETCH', id: '1' });
  assert(actor.getSnapshot().matches('loading'));

  // The invoked actor resolves asynchronously.
  // In a test, mock the fetchUser actor.
}

The ten parts build a data-fetching machine with loading, success, and error states, wire it to a component with useMachine, and show the global context and testing patterns. The key observation is that the machine’s transition table is the specification, and the component is a rendering of the current state. The async logic lives in the invoke declaration, not in useEffect.


Quick Reference

XState v5 Core API

FunctionPurpose
setup({ types, actions, actors })Type-safe machine configuration
createMachine({ ... })Define the machine
assign({ ... })Update context
fromPromise(fn)Async actor for invoke
createActor(machine)Run a machine outside React

React Hooks

HookPurpose
useMachine(machine)Create and run a machine, subscribe to all changes
useActorRef(machine)Create an actor ref without subscribing
useSelector(actorRef, selector)Subscribe to a derived value
createActorContext(machine)Global context with selector subscriptions

When to Use XState

ScenarioRecommendation
1–2 values, no transitionsuseState
3–5 states, sequentialuseReducer + discriminated union
6+ states, branchingXState
Parallel or hierarchical statesXState
Async orchestration (invoke)XState
Global app storeZustand, not XState

Common State Checks

CheckCorrect Form
State namesnapshot.matches('idle')
Context valuesnapshot.context.user
Selected valueuseSelector((s) => s.context.count)
Send eventsend({ type: 'FETCH', id: '1' })

Best Practices

✅ Do This:

// Use setup for typed machines.
setup({ types: { context: {} as Ctx, events: {} as Ev } })            // ✅

// Use matches for state checks.
snapshot.matches('loading')                                          // ✅

// Use useSelector for performance.
useSelector((s) => s.context.user)                                   // ✅

// Use createActorContext for global state.
const Ctx = createActorContext(machine)                              // ✅

// Keep machines pure, without React imports.
// The machine can be tested with createActor.                      // ✅

❌ Don’t Do This:

// Don't compare state.value for nested machines.
snapshot.value === 'loading' // fails for parallel states            // ❌

// Don't mutate context directly.
context.count += 1 // use assign()                                   // ❌

// Don't create a new machine on every render.
useMachine(createMachine({ ... })) // memoize or define outside       // ❌

// Don't use XState for a single boolean.
useMachine(toggleMachine) // useState is simpler                     // ❌

// Don't expect events to throw when no transition matches.
send({ type: 'UNKNOWN' }) // silently ignored                         // ❌

Common Pitfalls

PitfallWhy It HappensFix
State check failsvalue is an object for nested statesUse matches()
Context not updatingAction returned a value instead of assignUse assign({ ... })
Machine recreated per rendercreateMachine called inside the componentDefine the machine outside or memoize
Event has no effectNo transition for that event in the current stateCheck the transition table
Async actor result missinginvoke result is in event.output in v5Use event.output, not event.data
Tutorial code failsThe tutorial is for v4Use v5 documentation

Real-World Examples

1. Toggle Machine

createMachine({ initial: 'inactive', states: { inactive: { on: { TOGGLE: 'active' } }, active: { on: { TOGGLE: 'inactive' } } } })

2. Context with assign

actions: assign({ count: ({ context }) => context.count + 1 })

3. Invoke Async

invoke: { src: 'fetchUser', onDone: { target: 'success', actions: assign({ user: ({ event }) => event.output }) } }

4. useMachine

const [snapshot, send] = useMachine(machine);

5. matches

snapshot.matches('loading')

6. useSelector

useSelector((s) => s.context.count)

7. createActorContext

const Ctx = createActorContext(machine);

8. Provider

<Ctx.Provider><App /></Ctx.Provider>

9. Send Event

send({ type: 'FETCH', id: '1' })

10. Test with createActor

const actor = createActor(machine); actor.start(); actor.send({ type: 'TOGGLE' });

Visual

The Infinite vs Finite State Machine

┌─────────────────────────────────────────────────────────┐
│  useState (infinite combinations)                       │
│                                                         │
│  loading: boolean                                       │
│  error: string | null                                   │
│  data: User | null                                      │
│                                                         │
│  Possible: loading=true, error="x", data=null           │
│  Possible: loading=false, error=null, data=user         │
│  Possible: loading=true, error="x", data=user (BUG)     │
│                                                         │
│  ─────────────────────────────────────────────────────  │
│                                                         │
│  XState (finite, enforced)                              │
│                                                         │
│  idle → loading → success                               │
│                ↘ error → loading (retry)                │
│                                                         │
│  Only the declared transitions are possible.            │
│  An event with no transition is ignored.                │
└─────────────────────────────────────────────────────────┘

The Actor Lifecycle

┌─────────────────────────────────────────────────────────┐
│  createMachine()                                        │
│  → specification (pure, no side effects)                │
│         │                                               │
│         ▼  useMachine() or createActor()                │
│  Actor (running instance)                               │
│  → holds current state                                  │
│  → subscribes to events                                 │
│  → executes actions and invokes actors                  │
│         │                                               │
│         ▼  component unmounts or actor.stop()           │
│  Actor stopped                                          │
└─────────────────────────────────────────────────────────┘

The Selector Optimization

┌─────────────────────────────────────────────────────────┐
│  WITHOUT useSelector                                    │
│                                                         │
│  useMachine(machine) → snapshot                         │
│  Any state change → component re-renders                │
│  context.count changes, context.user changes,           │
│  state.value changes → all trigger re-render            │
│                                                         │
│  ─────────────────────────────────────────────────────  │
│                                                         │
│  WITH useSelector                                       │
│                                                         │
│  useSelector((s) => s.context.count)                    │
│  Only count changes → component re-renders              │
│  State changes that do not affect count → no re-render  │
└─────────────────────────────────────────────────────────┘

The Decision Tree

┌─────────────────────────────────────────────────────────┐
│  Does the UI have mutually exclusive states?            │
│         │                                               │
│    NO   │  YES                                          │
│    ▼    ▼                                               │
│  useState   How many states?                            │
│                   │                                     │
│         ┌─────────┴─────────┐                           │
│         ▼                   ▼                           │
│   2–5, sequential     6+, parallel/hierarchical         │
│         │                   │                           │
│         ▼                   ▼                           │
│   useReducer            XState                          │
│   + union                                   │
│         │                                               │
│         ▼  Async orchestration?                         │
│   Manual useEffect         XState invoke                │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Packagexstate + @xstate/react
Machine definitionsetup({ types, actions, actors }).createMachine()
React hookuseMachine(machine) → [snapshot, send, actorRef]
State checksnapshot.matches('name')
Context updateassign({ key: ({ context }) => value })
Async actorfromPromise(fn) + invoke
SelectoruseSelector(actorRef, selector)
Global contextcreateActorContext(machine)
TestcreateActor(machine)
When to use6+ states, parallel/hierarchical, async orchestration

Key takeaways:

  • XState makes illegal states unrepresentable. The transition table enforces which events are legal in which states at runtime. An event with no matching transition is silently ignored, not executed .
  • useMachine runs the machine and subscribes the component to every change. It returns the snapshot, the send function, and the actor ref in v5 .
  • useSelector prevents unnecessary re-renders. A component that only cares about one slice of the state subscribes to that slice and re-renders only when it changes .
  • createActorContext is the recommended global state pattern. It wraps the machine in a React context, creates the actor once at the provider, and exposes selector-based subscriptions to consumers .
  • Async logic belongs in invoke, not useEffect. The machine declares the async actor, the success path, and the error path. The component renders the state, not the orchestration.
  • The machine is testable without React. createActor runs the machine in isolation. Tests send events and assert on the resulting state, with no rendering and no mocking .
  • XState is not always the right tool. For 2–5 sequential states with no async orchestration, useReducer with a discriminated union is simpler and has no dependency cost .
  • v5 renamed event.data to event.output. Tutorials and examples written for v4 will fail with v5. Check the version before following a pattern .

Remember: A state machine is a specification of behavior, not a replacement for a state management library. XState earns its complexity when the interaction has many states, when the states have parallel or hierarchical structure, or when async orchestration is scattered across effects. For those cases, the machine makes the transitions explicit, the illegal states impossible, and the tests independent of React. For a toggle with two states, useState is the correct tool, and the state machine is an over-modeling that adds a dependency without adding clarity.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!