React 116 ⚛️ State Machines with XState in React
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
| Function | Purpose |
|---|---|
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
| Hook | Purpose |
|---|---|
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
| Scenario | Recommendation |
|---|---|
| 1–2 values, no transitions | useState |
| 3–5 states, sequential | useReducer + discriminated union |
| 6+ states, branching | XState |
| Parallel or hierarchical states | XState |
| Async orchestration (invoke) | XState |
| Global app store | Zustand, not XState |
Common State Checks
| Check | Correct Form |
|---|---|
| State name | snapshot.matches('idle') |
| Context value | snapshot.context.user |
| Selected value | useSelector((s) => s.context.count) |
| Send event | send({ 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| State check fails | value is an object for nested states | Use matches() |
| Context not updating | Action returned a value instead of assign | Use assign({ ... }) |
| Machine recreated per render | createMachine called inside the component | Define the machine outside or memoize |
| Event has no effect | No transition for that event in the current state | Check the transition table |
| Async actor result missing | invoke result is in event.output in v5 | Use event.output, not event.data |
| Tutorial code fails | The tutorial is for v4 | Use 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
| Item | Value |
|---|---|
| Package | xstate + @xstate/react |
| Machine definition | setup({ types, actions, actors }).createMachine() |
| React hook | useMachine(machine) → [snapshot, send, actorRef] |
| State check | snapshot.matches('name') |
| Context update | assign({ key: ({ context }) => value }) |
| Async actor | fromPromise(fn) + invoke |
| Selector | useSelector(actorRef, selector) |
| Global context | createActorContext(machine) |
| Test | createActor(machine) |
| When to use | 6+ 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 .
useMachineruns the machine and subscribes the component to every change. It returns the snapshot, thesendfunction, and the actor ref in v5 .useSelectorprevents 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 .createActorContextis 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, notuseEffect. 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.
createActorruns 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,
useReducerwith a discriminated union is simpler and has no dependency cost . - v5 renamed
event.datatoevent.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.
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!