Introduction
Every frontend developer has faced the nightmare of managing complex UI state: a modal that can be opening, open, closing, or closed; a form that handles validation, submission, success, and error states; or an async data fetch with loading, loaded, error, and empty states. The typical approach — boolean flags and useEffect chains — leads to impossible-to-debug state combinations where isLoading and isError are both true simultaneously.
State machines eliminate this entire class of bugs by design. A finite state machine can only be in one state at a time, and transitions between states are explicit and deterministic. You cannot accidentally reach an impossible state because the machine simply does not allow it.
XState is the most mature state machine library for JavaScript, built on the W3C SCXML specification. It provides a visual editor, comprehensive TypeScript support, and an actor model for orchestrating complex workflows. In this guide, we will build real state machines for common UI patterns, explore guards and actions, and integrate XState with React for production applications.
Understanding State Machines: Core Concepts
A finite state machine has four components:
- States: The possible conditions the system can be in (e.g.,
idle,loading,success,error) - Events: Things that happen that might trigger a transition (e.g.,
FETCH,SUCCESS,ERROR) - Transitions: Rules that determine which state follows which event from which current state
- Actions: Side effects that execute during transitions (e.g., API calls, logging, state updates)
The power is in the constraints. A transition table explicitly defines every valid path through your system. If an event arrives in a state where no transition is defined, the machine ignores it. This eliminates impossible states and undefined behavior by construction.
Consider a fetch operation. With boolean flags, you can have isLoading: true, isError: true, data: someData — a nonsensical state. With a state machine, the system is in exactly one of idle | loading | success | error, and each state has a clear set of valid next states.
XState extends basic state machines with hierarchical states (states within states), parallel states (independent state regions), guards (conditional transitions), actions (side effects), and invocations (spawning child actors for async work).
When to Use State Machines
State machines excel when:
- Your UI has multiple distinct states with specific transitions
- Boolean flags proliferate (
isLoading,isOpen,hasError,isSubmitting) - You need to handle edge cases like race conditions and cancellation
- Business rules define valid state transitions
- You need to test state logic independently of the UI
They add overhead when:
- State is simple (a single toggle or counter)
- The team is unfamiliar with the paradigm and the project is small
Architecture and Design Patterns
XState Machine Definition
An XState machine is defined as a configuration object:
import { createMachine, assign } from 'xstate';
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: {
data: null as unknown,
error: null as string | null,
retries: 0,
},
states: {
idle: {
on: {
FETCH: { target: 'loading' },
},
},
loading: {
invoke: {
src: 'fetchData',
onDone: { target: 'success', actions: 'setData' },
onError: { target: 'error', actions: 'setError' },
},
},
success: {
on: {
FETCH: { target: 'loading' },
RESET: { target: 'idle', actions: 'clearData' },
},
},
error: {
on: {
RETRY: { target: 'loading', actions: 'incrementRetries' },
RESET: { target: 'idle', actions: 'clearData' },
},
},
},
});Context and Actions
Context holds extended state — data that is not part of the state itself but is needed for logic and display. Actions are fire-and-forget side effects that update context, call external APIs, or send events to other actors.
const fetchMachine = createMachine({
context: {
data: null,
error: null,
retries: 0,
},
// ...
}, {
actions: {
setData: assign({
data: ({ event }) => event.output,
retries: 0,
}),
setError: assign({
error: ({ event }) => event.data.message,
}),
clearData: assign({
data: null,
error: null,
retries: 0,
}),
incrementRetries: assign({
retries: ({ context }) => context.retries + 1,
}),
},
});assign() is the key action for updating context. It receives the current context and event, and returns partial context updates. Actions are pure by convention — they should not have side effects beyond context updates.
Guards (Guards)
Guards are conditions on transitions. They determine whether a transition should be taken based on the current context and event:
const formMachine = createMachine({
context: {
email: '',
password: '',
attempts: 0,
},
initial: 'editing',
states: {
editing: {
on: {
SUBMIT: {
target: 'submitting',
guard: 'isFormValid',
},
},
},
submitting: {
// ...
},
},
}, {
guards: {
isFormValid: ({ context }) => {
return context.email.includes('@') && context.password.length >= 8;
},
canRetry: ({ context }) => context.attempts < 3,
},
});If the guard returns false, the transition is not taken. The machine stays in the current state as if the event never happened.
Step-by-Step Implementation
Building a Multi-Step Form
Let us build a complete multi-step registration form with validation, async submission, and error handling:
import { createMachine, assign, setup } from 'xstate';
interface FormContext {
step: number;
data: {
name: string;
email: string;
password: string;
};
errors: Record<string, string>;
submissionError: string | null;
}
const registrationMachine = setup({
types: {} as {
context: FormContext;
events:
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'UPDATE_FIELD'; field: string; value: string }
| { type: 'SUBMIT' }
| { type: 'RETRY' };
},
guards: {
isStep1Valid: ({ context }) => context.data.name.length >= 2,
isStep2Valid: ({ context }) =>
context.data.email.includes('@') && context.data.email.includes('.'),
isStep3Valid: ({ context }) => context.data.password.length >= 8,
},
actions: {
updateField: assign({
data: ({ context, event }) => ({
...context.data,
[event.field]: event.value,
}),
errors: ({ context, event }) => {
const { [event.field]: _, ...rest } = context.errors;
return rest;
},
}),
clearErrors: assign({ errors: {} }),
setSubmissionError: assign({
submissionError: ({ event }) => event.data.message,
}),
clearSubmissionError: assign({ submissionError: null }),
resetForm: assign({
step: 1,
data: { name: '', email: '', password: '' },
errors: {},
submissionError: null,
}),
},
actors: {
submitRegistration: async ({ context }) => {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(context.data),
});
if (!response.ok) throw new Error(await response.text());
return response.json();
},
},
}).createMachine({
id: 'registration',
initial: 'step1',
context: {
step: 1,
data: { name: '', email: '', password: '' },
errors: {},
submissionError: null,
},
states: {
step1: {
entry: assign({ step: 1 }),
on: {
UPDATE_FIELD: { actions: 'updateField' },
NEXT: { target: 'step2', guard: 'isStep1Valid' },
},
},
step2: {
entry: assign({ step: 2 }),
on: {
UPDATE_FIELD: { actions: 'updateField' },
PREV: { target: 'step1' },
NEXT: { target: 'step3', guard: 'isStep2Valid' },
},
},
step3: {
entry: assign({ step: 3 }),
on: {
UPDATE_FIELD: { actions: 'updateField' },
PREV: { target: 'step2' },
SUBMIT: { target: 'submitting', guard: 'isStep3Valid' },
},
},
submitting: {
entry: 'clearSubmissionError',
invoke: {
src: 'submitRegistration',
onDone: { target: 'success' },
onError: { target: 'step3', actions: 'setSubmissionError' },
},
},
success: {
on: {
RETRY: { target: 'step1', actions: 'resetForm' },
},
},
},
});Integrating with React
XState v5 provides useMachine for React integration:
import { useMachine } from '@xstate/react';
import { registrationMachine } from './registrationMachine';
function RegistrationForm() {
const [state, send] = useMachine(registrationMachine);
const handleFieldChange = (field: string, value: string) => {
send({ type: 'UPDATE_FIELD', field, value });
};
if (state.matches('success')) {
return (
<div>
<h2>Registration Complete!</h2>
<button onClick={() => send({ type: 'RETRY' })}>Register Another</button>
</div>
);
}
return (
<form onSubmit={(e) => { e.preventDefault(); send({ type: 'SUBMIT' }); }}>
{state.matches('step1') && (
<div>
<input
value={state.context.data.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
placeholder="Name"
/>
<button type="button" onClick={() => send({ type: 'NEXT' })}>
Next
</button>
</div>
)}
{state.matches('step2') && (
<div>
<input
value={state.context.data.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
placeholder="Email"
/>
<button type="button" onClick={() => send({ type: 'PREV' })}>Back</button>
<button type="button" onClick={() => send({ type: 'NEXT' })}>Next</button>
</div>
)}
{state.matches('step3') && (
<div>
<input
type="password"
value={state.context.data.password}
onChange={(e) => handleFieldChange('password', e.target.value)}
placeholder="Password"
/>
{state.context.submissionError && (
<p className="error">{state.context.submissionError}</p>
)}
<button type="button" onClick={() => send({ type: 'PREV' })}>Back</button>
<button type="submit">Submit</button>
</div>
)}
</form>
);
}Building a Data Fetching Machine
A reusable data fetching pattern with caching and retry logic:
import { createMachine, assign, setup } from 'xstate';
function createFetchMachine<T>(fetchFn: () => Promise<T>) {
return setup({
actors: {
fetch: async () => fetchFn(),
},
actions: {
setData: assign({
data: ({ event }) => event.output as T,
error: null,
retries: 0,
}),
setError: assign({
error: ({ event }) => event.data as Error,
}),
incrementRetries: assign({
retries: ({ context }) => context.retries + 1,
}),
},
guards: {
canRetry: ({ context }) => context.retries < 3,
},
}).createMachine({
id: 'fetch',
initial: 'idle',
context: {
data: null as T | null,
error: null as Error | null,
retries: 0,
},
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
invoke: {
src: 'fetch',
onDone: 'success',
onError: 'failure',
},
},
success: {
entry: 'setData',
on: {
FETCH: 'loading',
INVALIDATE: 'loading',
},
},
failure: {
entry: 'setError',
on: {
RETRY: {
target: 'loading',
guard: 'canRetry',
actions: 'incrementRetries',
},
FETCH: 'loading',
},
},
},
});
}Real-World Use Cases
Use Case 1: Media Player State Machine
A music player has complex state interactions — play, pause, skip, seek, shuffle, repeat — with many invalid transitions:
const playerMachine = setup({
types: {} as {
context: {
track: string | null;
position: number;
duration: number;
volume: number;
isShuffle: boolean;
repeatMode: 'none' | 'one' | 'all';
};
events:
| { type: 'PLAY'; track?: string }
| { type: 'PAUSE' }
| { type: 'STOP' }
| { type: 'SEEK'; position: number }
| { type: 'VOLUME'; level: number }
| { type: 'TOGGLE_SHUFFLE' }
| { type: 'TOGGLE_REPEAT' };
},
}).createMachine({
id: 'player',
initial: 'stopped',
context: {
track: null,
position: 0,
duration: 0,
volume: 0.8,
isShuffle: false,
repeatMode: 'none',
},
states: {
stopped: {
on: {
PLAY: { target: 'playing' },
},
},
playing: {
on: {
PAUSE: { target: 'paused' },
STOP: { target: 'stopped' },
SEEK: { actions: assign({ position: ({ event }) => event.position }) },
},
},
paused: {
on: {
PLAY: { target: 'playing' },
STOP: { target: 'stopped' },
},
},
},
on: {
VOLUME: { actions: assign({ volume: ({ event }) => event.level }) },
TOGGLE_SHUFFLE: { actions: assign({ isShuffle: ({ context }) => !context.isShuffle }) },
TOGGLE_REPEAT: {
actions: assign({
repeatMode: ({ context }) => {
const modes = ['none', 'one', 'all'] as const;
const idx = modes.indexOf(context.repeatMode);
return modes[(idx + 1) % modes.length];
},
}),
},
},
});Use Case 2: WebSocket Connection Manager
Managing WebSocket connections involves reconnection logic, heartbeat monitoring, and graceful degradation:
const wsMachine = setup({
actors: {
connect: ({ context }) => {
return new Promise((resolve, reject) => {
const ws = new WebSocket(context.url);
ws.onopen = () => resolve(ws);
ws.onerror = (e) => reject(e);
});
},
},
}).createMachine({
id: 'websocket',
initial: 'disconnected',
context: {
url: 'wss://api.example.com/ws',
retries: 0,
maxRetries: 5,
socket: null as WebSocket | null,
},
states: {
disconnected: {
on: { CONNECT: 'connecting' },
},
connecting: {
invoke: {
src: 'connect',
onDone: { target: 'connected', actions: assign({ socket: ({ event }) => event.output, retries: 0 }) },
onError: { target: 'reconnecting' },
},
},
connected: {
on: {
DISCONNECT: { target: 'disconnected' },
CONNECTION_LOST: 'reconnecting',
},
},
reconnecting: {
after: {
1000: [
{ target: 'connecting', guard: ({ context }) => context.retries < 5, actions: assign({ retries: ({ context }) => context.retries + 1 }) },
{ target: 'failed' },
],
},
},
failed: {
on: { RETRY: { target: 'connecting', actions: assign({ retries: 0 }) } },
},
},
});Use Case 3: Authentication Flow
A complete auth flow with login, token refresh, and session management:
const authMachine = setup({
actors: {
login: async ({ context }) => {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email: context.email, password: context.password }),
});
if (!res.ok) throw new Error('Invalid credentials');
return res.json();
},
refreshToken: async ({ context }) => {
const res = await fetch('/api/refresh', {
method: 'POST',
headers: { Authorization: `Bearer ${context.refreshToken}` },
});
if (!res.ok) throw new Error('Token refresh failed');
return res.json();
},
},
}).createMachine({
id: 'auth',
initial: 'anonymous',
context: {
email: '',
password: '',
token: null as string | null,
refreshToken: null as string | null,
user: null as object | null,
},
states: {
anonymous: {
on: {
LOGIN: { target: 'authenticating' },
SET_CREDENTIALS: { actions: assign({ email: ({ event }) => event.email, password: ({ event }) => event.password }) },
},
},
authenticating: {
invoke: {
src: 'login',
onDone: {
target: 'authenticated',
actions: assign({
token: ({ event }) => event.output.token,
refreshToken: ({ event }) => event.output.refreshToken,
user: ({ event }) => event.output.user,
password: '',
}),
},
onError: { target: 'anonymous' },
},
},
authenticated: {
after: {
300000: { target: 'refreshing' }, // Refresh after 5 minutes
},
on: { LOGOUT: { target: 'anonymous', actions: assign({ token: null, refreshToken: null, user: null }) } },
},
refreshing: {
invoke: {
src: 'refreshToken',
onDone: { target: 'authenticated', actions: assign({ token: ({ event }) => event.output.token }) },
onError: { target: 'anonymous', actions: assign({ token: null, refreshToken: null, user: null }) },
},
},
},
});Best Practices for Production
-
Start with a state diagram: Before writing code, draw the state diagram. Use the XState visual editor (stately.ai/viz) to design and validate your machine visually.
-
Keep context minimal: Context should hold data, not behavior. Derived values should be computed in selectors, not stored in context.
-
Use guards for conditional logic: Guards are testable and visible in the state diagram. Conditional logic in actions is harder to reason about.
-
Model every event explicitly: Define what every event does in every state. Use
internal: falsefor transitions that exit and re-enter states (triggering entry/exit actions). -
Separate machine logic from UI: Define machines in separate files. The React component only calls
send()and readsstate. This makes machines testable without rendering. -
Use the actor model for complex workflows: When you need multiple machines communicating, use XState's actor model. A parent machine spawns child machines and communicates via events.
-
Test state machines with path coverage: Test every valid path through the machine, not just happy paths. XState provides
getShortestPaths()andgetSimplePaths()for generating test cases. -
Version your state machines: When deploying state machines to production, version them. If context shape changes, provide migration logic for persisted state.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Too many states | Machine becomes unmaintainable | Use hierarchical states to group related states |
| Storing derived data in context | Redundant, can get out of sync | Compute derived values in selectors |
| Actions with side effects | Hard to test, unpredictable | Keep actions pure; use actors for side effects |
| Not handling all events in each state | Events silently ignored | Use explicit on for every relevant event |
| Over-using guards | Complex conditions hard to debug | Keep guards simple; compose multiple guards |
| Persisting full machine state | Serialization issues with functions | Persist only context and current state value |
Performance Optimization
XState is lightweight, but complex machines with many states and guards can benefit from optimization:
import { createMachine } from 'xstate';
// Memoized selectors for derived state
const selectors = {
canSubmit: (state) =>
state.matches('step3') && state.context.data.password.length >= 8,
currentStepLabel: (state) => {
const labels = { step1: 'Personal Info', step2: 'Contact', step3: 'Security' };
return labels[state.value] || 'Unknown';
},
isNavigating: (state) =>
state.matches('step1') || state.matches('step2') || state.matches('step3'),
};For machines invoked frequently (inside lists), use useSelector to subscribe to specific slices:
import { useSelector } from '@xstate/react';
function TodoItem({ actor }) {
const isCompleted = useSelector(actor, (state) => state.matches('completed'));
return <li className={isCompleted ? 'done' : ''}>{/* ... */}</li>;
}Testing Strategies
import { describe, test, expect } from 'vitest';
import { createActor } from 'xstate';
import { registrationMachine } from './registrationMachine';
describe('registrationMachine', () => {
test('starts at step 1', () => {
const actor = createActor(registrationMachine);
actor.start();
expect(actor.getSnapshot().value).toBe('step1');
});
test('cannot advance to step 2 with empty name', () => {
const actor = createActor(registrationMachine);
actor.start();
actor.send({ type: 'NEXT' });
expect(actor.getSnapshot().value).toBe('step1'); // Guard blocks transition
});
test('advances through all steps and submits', async () => {
const actor = createActor(registrationMachine);
actor.start();
// Step 1
actor.send({ type: 'UPDATE_FIELD', field: 'name', value: 'John Doe' });
actor.send({ type: 'NEXT' });
expect(actor.getSnapshot().value).toBe('step2');
// Step 2
actor.send({ type: 'UPDATE_FIELD', field: 'email', value: 'john@example.com' });
actor.send({ type: 'NEXT' });
expect(actor.getSnapshot().value).toBe('step3');
// Step 3
actor.send({ type: 'UPDATE_FIELD', field: 'password', value: 'securepassword123' });
actor.send({ type: 'SUBMIT' });
expect(actor.getSnapshot().value).toBe('submitting');
});
test('back navigation preserves context', () => {
const actor = createActor(registrationMachine);
actor.start();
actor.send({ type: 'UPDATE_FIELD', field: 'name', value: 'Jane' });
actor.send({ type: 'NEXT' });
actor.send({ type: 'PREV' });
expect(actor.getSnapshot().value).toBe('step1');
expect(actor.getSnapshot().context.data.name).toBe('Jane');
});
});Future Outlook
XState v5 brings significant improvements: smaller bundle size, better TypeScript inference, and the setup API for cleaner machine composition. The Stately ecosystem is expanding with Stately Studio (visual editor), Stately Sky (hosted actors), and Stately Inspector (devtools).
The actor model is gaining traction beyond UI state. XState machines are being used for backend workflow orchestration, API gateway routing, and IoT device management. The W3C SCXML specification ensures that state machine knowledge transfers across languages and platforms.
The convergence of visual state modeling with AI-assisted development is particularly promising. Stately's AI features can generate state machines from natural language descriptions, lowering the barrier to adoption.
Conclusion
State machines transform chaotic boolean-flag state management into deterministic, testable, and visual systems. The key takeaways are:
- State machines enforce valid state transitions by design, eliminating impossible states
- XState provides a complete implementation of the SCXML specification for JavaScript
- Guards enable conditional transitions that are visible and testable
- Actions and actors separate side effects from state logic
- React integration via
useMachineconnects machines to UI components - Visual state modeling (Stately) dramatically improves design and communication
- Test every path through the machine, not just happy paths
Start by modeling your most complex UI component as a state machine. The clarity you gain from explicit state definitions and valid transitions will change how you think about application state permanently.