ne TypeScript Schema and Context
Strong typing is non-negotiable in production. Define the schema for events and context to ensure type safety throughout the machine and React integration.
import { createMachine, assign, createSchema } from 'xstate';
// Define context shape
interface PaymentContext {
amount: number;
transactionId: string | null;
error: string | null;
retryCount: number;
}
// Define event types
type PaymentEvent =
| { type: 'SUBMIT'; amount: number }
| { type: 'CANCEL' }
| { type: 'RETRY' }
| { type: 'PAYMENT_SUCCESS'; transactionId: string }
| { type: 'PAYMENT_FAILED'; error: string };
// Create schema for type inference
const paymentSchema = createSchema<PaymentContext>();
Step 2: Create the State Machine
The machine definition encapsulates all logic. Note the use of invoke for asynchronous operations, guards for conditional transitions, and assign for context updates.
const paymentMachine = createMachine({
id: 'payment',
schema: paymentSchema,
initial: 'idle',
context: {
amount: 0,
transactionId: null,
error: null,
retryCount: 0,
},
states: {
idle: {
on: {
SUBMIT: {
target: 'processing',
actions: assign({ amount: ({ event }) => event.amount }),
},
},
},
processing: {
invoke: {
src: ({ context }) => processPayment(context.amount),
onDone: {
target: 'success',
actions: assign({
transactionId: ({ event }) => event.output.transactionId,
error: null,
}),
},
onError: {
target: 'error',
actions: assign({
error: ({ event }) => event.data.message,
retryCount: ({ context }) => context.retryCount + 1,
}),
},
},
on: {
CANCEL: { target: 'idle' },
},
},
error: {
on: {
RETRY: {
target: 'processing',
guard: ({ context }) => context.retryCount < 3,
},
SUBMIT: { target: 'processing' },
},
},
success: {
type: 'final',
},
},
});
// Mock async service
function processPayment(amount: number): Promise<{ transactionId: string }> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.2) {
resolve({ transactionId: `txn_${Date.now()}` });
} else {
reject(new Error('Gateway Timeout'));
}
}, 1500);
});
}
Step 3: Integrate with React
Use the useMachine hook from @xstate/react. This hook manages the machine lifecycle and triggers re-renders only when the state changes, optimizing performance.
import { useMachine } from '@xstate/react';
export function PaymentComponent() {
const [state, send] = useMachine(paymentMachine);
return (
<div>
{state.matches('idle') && (
<button onClick={() => send({ type: 'SUBMIT', amount: 100 })}>
Pay $100
</button>
)}
{state.matches('processing') && (
<div>
<Spinner />
<button onClick={() => send({ type: 'CANCEL' })}>Cancel</button>
</div>
)}
{state.matches('error') && (
<div>
<p>Error: {state.context.error}</p>
{state.context.retryCount < 3 && (
<button onClick={() => send({ type: 'RETRY' })}>
Retry ({3 - state.context.retryCount} left)
</button>
)}
</div>
)}
{state.matches('success') && (
<p>Success! Transaction: {state.context.transactionId}</p>
)}
</div>
);
}
Architecture Decisions
- Machine vs. Component Logic: All business logic, including retries, error handling, and state transitions, resides in the machine. The component becomes a pure view layer, mapping state to UI.
- Invoke for Side Effects: Asynchronous operations are handled via
invoke, which automatically subscribes to the promise and handles cancellation if the state changes before resolution.
- Context for Data: Transient data (like
transactionId) is stored in context, ensuring it persists across transitions and is available for actions and guards.
- Final States: Using
type: 'final' allows the machine to be composed within larger workflows or triggers specific cleanup logic in the React component via state.done.
Pitfall Guide
1. Over-Modeling Simple Interactions
Mistake: Creating a state machine for a simple toggle or a form with linear validation.
Explanation: State machines introduce overhead. If the state space is small and linear, useState or useReducer is more efficient. Reserve XState for flows with branching logic, asynchronous operations, or error recovery paths.
Best Practice: Use the "Boolean Test." If you have three or more boolean flags interacting, consider a machine.
2. Ignoring TypeScript Schemas
Mistake: Defining machines without createSchema or type annotations.
Explanation: Without schemas, you lose type safety on events and context. This leads to runtime errors and negates one of XState's primary benefits.
Best Practice: Always define context and event types and pass them to createMachine via the schema option.
3. Missing Error Transitions
Mistake: Defining invoke without onError handlers.
Explanation: If a promise rejects and there is no onError transition, the machine may halt or enter an undefined state.
Best Practice: Every invoke must have an onError path to a recoverable state, such as an error screen or a retry state.
4. Deeply Nested Hierarchies
Mistake: Creating machines with excessive nesting levels.
Explanation: Deep hierarchies make the machine hard to visualize and debug. Transitions across deep boundaries can be confusing.
Best Practice: Keep hierarchies flat. Use parallel states (type: 'parallel') to model independent concerns rather than deep nesting.
5. Mutating Context Directly in Actions
Mistake: Modifying context objects inside actions without using assign.
Explanation: XState relies on immutable updates. Direct mutation can cause stale state in the React component or break time-travel debugging.
Best Practice: Always use assign to update context. Actions should be pure functions that trigger state changes via transitions.
6. Blocking Transitions with Guards
Mistake: Using guards that always return false without a fallback transition.
Explanation: If a guard blocks a transition and no other transition matches the event, the event is ignored. This can make the UI appear unresponsive.
Best Practice: Ensure every event has a valid path or explicitly handle ignored events in a fallback state.
7. Not Leveraging the Visualizer
Mistake: Developing machines without visual feedback.
Explanation: The XState Visualizer provides immediate feedback on state paths, unreachable states, and transition logic.
Best Practice: Run state.visualizer() during development to inspect the machine graph and verify transitions.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Toggle | useState | Low overhead, sufficient for binary state. | Low |
| Multi-Step Wizard | XState | Enforces flow order and validates steps. | Medium |
| WebSocket Stream | XState | Manages connection lifecycle and message handling. | High Savings |
| Form with Validation | XState | Handles validation states and submission flow. | Medium |
| Global Auth State | XState + Context | Shared logic across components with persistence. | Medium |
| Animation Sequence | XState | Precise control over animation frames and states. | High |
Configuration Template
A reusable template for defining XState machines with TypeScript and React integration.
// machine.template.ts
import { createMachine, createSchema, assign } from 'xstate';
import { useMachine } from '@xstate/react';
// 1. Define Context and Events
interface MyContext {
data: any;
loading: boolean;
error: string | null;
}
type MyEvent =
| { type: 'FETCH' }
| { type: 'FETCH_SUCCESS'; data: any }
| { type: 'FETCH_ERROR'; error: string }
| { type: 'RESET' };
// 2. Create Schema
const mySchema = createSchema<MyContext>();
// 3. Define Machine
export const myMachine = createMachine({
id: 'myMachine',
schema: mySchema,
initial: 'idle',
context: {
data: null,
loading: false,
error: null,
},
states: {
idle: {
on: {
FETCH: { target: 'loading' },
},
},
loading: {
invoke: {
src: () => fetchData(),
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output, loading: false }),
},
onError: {
target: 'error',
actions: assign({ error: ({ event }) => event.data.message, loading: false }),
},
},
on: {
RESET: { target: 'idle' },
},
},
success: {
on: {
FETCH: { target: 'loading' },
RESET: { target: 'idle' },
},
},
error: {
on: {
FETCH: { target: 'loading' },
RESET: { target: 'idle' },
},
},
},
});
// 4. React Hook Usage
export function useMyMachine() {
return useMachine(myMachine);
}
// Mock Service
function fetchData(): Promise<any> {
return Promise.resolve({ id: 1, value: 'data' });
}
Quick Start Guide
-
Install Dependencies:
npm install xstate @xstate/react
-
Create Machine Definition:
Create a file paymentMachine.ts and paste the machine definition from the Core Solution.
-
Integrate in Component:
Import useMachine and the machine, then replace existing state logic with the hook.
-
Run Visualizer:
Add state.visualizer() in your component to open the visualizer in the browser for debugging.
-
Verify Transitions:
Interact with the UI and observe the state changes in the visualizer to ensure all paths are covered.
Conclusion: XState provides a robust framework for managing complex state in React applications. By enforcing deterministic transitions, eliminating impossible states, and providing visual debugging tools, XState reduces bug density and improves developer confidence. While there is an initial learning curve, the long-term benefits in maintainability and reliability make it a critical tool for senior engineering teams building scalable React applications.