
React State Machine
- 75 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
react-state-machine is a reference skill for building React state machines with XState v5 and the actor model, covering setup(), promise actors, guards, and testing.
About
This skill is a reference for modeling React UI logic as state machines with XState v5 and the actor model. It covers the setup() pattern, promise actors for async flows, useMachine and useSelector integration, guards, testing with createActor, and visualization in Stately Studio. A developer uses it for complex async flows, multi-step forms, or when boolean state flags proliferate.
- Type-safe React state machines with XState v5 and the actor model
- setup() pattern, promise actors, guards, and useMachine/useSelector integration
- Testing with createActor and visualization in Stately Studio
React State Machine by the numbers
- 75 all-time installs (skills.sh)
- Ranked #1,131 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
react-state-machine capabilities & compatibility
- Capabilities
- react development · state management · async flow modeling
- Use cases
- frontend · refactoring
- Runs
- Runs locally
- Pricing
- Free
What react-state-machine says it does
State machines make impossible states unrepresentable by modeling UI behavior as explicit states, transitions, and events.
XState v5 (2.5M+ weekly npm downloads) unifies state machines with the actor model
Boolean flag explosion: multiple `isLoading`, `isError`, `isSuccess` flags
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill react-state-machineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Reference for building type-safe React state machines with XState v5 for complex async flows and multi-step UI logic.
Who is it for?
Developers modeling complex async flows, multi-step forms, or UI with proliferating boolean flags.
Skip if: Simple toggles, single-field forms, or server-state caching (use useState or React Query).
When should I use this skill?
Handling complex async flows, multi-step forms, modal animations, boolean flag explosion, or defensive coding patterns.
What you get
Explicit, type-safe state machines that make impossible UI states unrepresentable.
By the numbers
- XState v5 cited at 2.5M+ weekly npm downloads
- Bundles 11 reference files (patterns, integration, testing, error-handling, and more)
Files
React State Machines with XState v5
Overview
State machines make impossible states unrepresentable by modeling UI behavior as explicit states, transitions, and events. XState v5 (2.5M+ weekly npm downloads) unifies state machines with the actor model—every machine is an independent entity with its own lifecycle, enabling sophisticated composition patterns.
When to Use This Skill
Trigger patterns:
- Boolean flag explosion: multiple
isLoading,isError,isSuccessflags - Implicit states: writing
if (isLoading && !isError && data)to derive mode - Defensive coding: guards before state updates to prevent invalid transitions
- Timing coordination: timeouts, delays, debouncing across states
- State dependencies: one state depends on another to update correctly
Do not use for:
- Simple boolean toggles with no async (useState is simpler)
- Single form fields with basic validation (useReducer suffices)
- Server state caching (React Query/TanStack Query handles this)
- Static data transformations (useMemo is better)
- Simple counters or toggles (useState is clearer)
See decision-trees.md for comprehensive decision guidance
Core Mental Model
Finite states represent modes of behavior: idle, loading, success, error. A component can only be in ONE state at a time.
Context (extended state) stores quantitative data that doesn't define distinct states. The finite state says "playing"; context says what at what volume.
Events trigger transitions between states. Events are objects: { type: 'SUBMIT', data: formData }.
Guards conditionally allow/block transitions: { guard: 'hasValidInput' }.
Actions are fire-and-forget side effects during transitions or state entry/exit.
Invoked actors are long-running processes (API calls, subscriptions) with lifecycle management and cleanup.
Quick Start: XState v5 setup() Pattern
import { setup, assign, fromPromise } from 'xstate';
const fetchMachine = setup({
types: {
context: {} as { data: User | null; error: string | null },
events: {} as
| { type: 'FETCH'; userId: string }
| { type: 'RETRY' }
},
actors: {
fetchUser: fromPromise(async ({ input, signal }) => {
const res = await fetch(`/api/users/${input.userId}`, { signal });
if (!res.ok) throw new Error(res.statusText);
return res.json();
})
},
actions: {
setData: assign({ data: ({ event }) => event.output }),
setError: assign({ error: ({ event }) => event.error.message })
}
}).createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: { on: { FETCH: 'loading' } },
loading: {
invoke: {
src: 'fetchUser',
input: ({ event }) => ({ userId: event.userId }),
onDone: { target: 'success', actions: 'setData' },
onError: { target: 'failure', actions: 'setError' }
}
},
success: { on: { FETCH: 'loading' } },
failure: { on: { RETRY: 'loading' } }
}
});React Integration Decision Tree
| Use Case | Hook | Why |
|---|---|---|
| Simple component state | useMachine | Straightforward, re-renders on all changes |
| Performance-critical | useActorRef + useSelector | Selective re-renders only |
| Global/shared state | createActorContext | React Context integration |
Basic pattern:
import { useMachine } from '@xstate/react';
function Toggle() {
const [snapshot, send] = useMachine(toggleMachine);
return (
<button onClick={() => send({ type: 'TOGGLE' })}>
{snapshot.matches('inactive') ? 'Off' : 'On'}
</button>
);
}Performance pattern:
import { useActorRef, useSelector } from '@xstate/react';
const selectCount = (s) => s.context.count;
const selectLoading = (s) => s.matches('loading');
function Counter() {
const actorRef = useActorRef(counterMachine);
const count = useSelector(actorRef, selectCount);
const loading = useSelector(actorRef, selectLoading);
// Only re-renders when count or loading changes
}Anti-Patterns to Avoid
❌ State explosion: Flat states for orthogonal concerns. Use parallel states instead.
❌ Sending events from actions: Never send() inside assign. Use raise for internal events.
❌ Impure guards: Guards must be pure—no side effects, no external mutations.
❌ Subscribing to entire state: Use focused selectors with useSelector.
❌ Not memoizing model:
// WRONG
const model = Model.fromJson(layout); // New model every render
// CORRECT
const modelRef = useRef(Model.fromJson(layout));Navigation to References
Core Patterns
- xstate-v5-patterns.md: Complete v5 API, statecharts (hierarchy/parallel/history), promise actors
- react-integration.md: useMachine vs useActorRef, Context patterns, side effect handling
- testing-patterns.md: Unit testing, mocking actors, visualization debugging
Decision Making & Best Practices
- decision-trees.md: When to use state machines vs useState/useReducer/React Query, machine splitting strategies
- real-world-patterns.md: Complete examples - auth flows, file uploads, wizards, undo/redo, shopping carts
- error-handling.md: Error boundaries, retry strategies, circuit breakers, graceful degradation
- performance.md: Selector memoization, React.memo integration, machine splitting for performance
Advanced Topics
- persistence-hydration.md: localStorage persistence, SSR/Next.js hydration, snapshot serialization
- migration-guide.md: Step-by-step migration from useState/useReducer with before/after examples
- composition-patterns.md: Actor communication, machine composition, higher-order machines, systemId
- skills-architecture.md: Input/output parameterization, library structure
Key Reminders
1. setup() is the v5 way: Strong TypeScript inference, actor registration, action definitions 2. Invoke for async, actions for sync: Actions are fire-and-forget; invoked actors have lifecycle 3. Finite states for modes, context for data: Don't create states for every data variation 4. Visualize first: Stately Studio (stately.ai/editor) makes machines living documentation
Red Flags
- More than 3-4 boolean flags → Need state machine
- Writing
if (a && !b && c)to determine mode → States should be explicit - Bugs from invalid state combinations → Machine prevents impossible states
- Can't explain state transitions to stakeholders → Visualization solves this
Related Skills
- react: Parent skill for React patterns
- nextjs: Server/client state coordination
- test-driven-development: Test machines with createActor before UI integration
{
"name": "react-state-machine",
"version": "1.0.1",
"category": "toolchain",
"toolchain": "javascript",
"framework": "react",
"tags": [
"react",
"state-machine",
"xstate",
"actors",
"async",
"forms",
"ui-logic",
"typescript"
],
"entry_point_tokens": 150,
"full_tokens": 37323,
"related_skills": [
"react-advanced",
"toolchains/javascript/frameworks/react",
"toolchains/javascript/frameworks/nextjs",
"universal/testing/test-driven-development"
],
"author": "bobmatnyc",
"license": "MIT",
"requires": [],
"updated": "2026-06-15",
"source_path": "toolchains/javascript/frameworks/react/react-state-machine/SKILL.md",
"source": "https://github.com/bobmatnyc/claude-mpm",
"created": "2025-11-29",
"modified": "2026-06-15",
"maintainer": "Claude MPM Team",
"attribution_required": true,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
Composition Patterns: Building Complex Systems
Actor Communication
Parent-Child Communication with sendTo
import { setup, assign, sendTo } from 'xstate';
// Child machine
const childMachine = setup({
types: {
context: {} as { count: number },
events: {} as
| { type: 'INCREMENT' }
| { type: 'RESET' }
}
}).createMachine({
id: 'child',
initial: 'active',
context: { count: 0 },
states: {
active: {
on: {
INCREMENT: {
actions: assign({ count: ({ context }) => context.count + 1 })
},
RESET: {
actions: assign({ count: 0 })
}
}
}
}
});
// Parent machine
const parentMachine = setup({
types: {
events: {} as
| { type: 'INCREMENT_CHILD' }
| { type: 'RESET_CHILD' }
},
actors: {
child: childMachine
}
}).createMachine({
id: 'parent',
initial: 'active',
states: {
active: {
invoke: {
id: 'childActor',
src: 'child'
},
on: {
INCREMENT_CHILD: {
actions: sendTo('childActor', { type: 'INCREMENT' })
},
RESET_CHILD: {
actions: sendTo('childActor', { type: 'RESET' })
}
}
}
}
});Child-to-Parent Communication with sendParent
import { setup, sendParent } from 'xstate';
// Child notifies parent
const notifyingChildMachine = setup({
types: {
events: {} as { type: 'COMPLETE_TASK' }
}
}).createMachine({
id: 'notifyingChild',
initial: 'working',
states: {
working: {
on: {
COMPLETE_TASK: {
target: 'done',
actions: sendParent({ type: 'CHILD_COMPLETED' })
}
}
},
done: {
type: 'final'
}
}
});
// Parent listens for child events
const listeningParentMachine = setup({
types: {
context: {} as { completedTasks: number },
events: {} as { type: 'CHILD_COMPLETED' }
},
actors: {
task: notifyingChildMachine
}
}).createMachine({
id: 'listeningParent',
initial: 'active',
context: { completedTasks: 0 },
states: {
active: {
invoke: {
id: 'taskActor',
src: 'task'
},
on: {
CHILD_COMPLETED: {
actions: assign({
completedTasks: ({ context }) => context.completedTasks + 1
})
}
}
}
}
});Bidirectional Communication
import { setup, assign, sendTo, sendParent } from 'xstate';
// Worker machine
const workerMachine = setup({
types: {
context: {} as { workload: number },
events: {} as
| { type: 'ASSIGN_WORK'; amount: number }
| { type: 'COMPLETE_WORK' }
}
}).createMachine({
id: 'worker',
initial: 'idle',
context: { workload: 0 },
states: {
idle: {
on: {
ASSIGN_WORK: {
target: 'working',
actions: assign({ workload: ({ event }) => event.amount })
}
}
},
working: {
on: {
COMPLETE_WORK: {
target: 'idle',
actions: [
assign({ workload: 0 }),
sendParent({ type: 'WORK_COMPLETED' })
]
}
}
}
}
});
// Manager machine
const managerMachine = setup({
types: {
context: {} as { completedWork: number },
events: {} as
| { type: 'ASSIGN_TASK' }
| { type: 'WORK_COMPLETED' }
},
actors: {
worker: workerMachine
}
}).createMachine({
id: 'manager',
initial: 'managing',
context: { completedWork: 0 },
states: {
managing: {
invoke: {
id: 'workerActor',
src: 'worker'
},
on: {
ASSIGN_TASK: {
actions: sendTo('workerActor', { type: 'ASSIGN_WORK', amount: 10 })
},
WORK_COMPLETED: {
actions: assign({
completedWork: ({ context }) => context.completedWork + 1
})
}
}
}
}
});Machine Composition
Hierarchical Composition (Parent-Child)
import { setup } from 'xstate';
// Reusable form field machine
const fieldMachine = setup({
types: {
context: {} as { value: string; error: string | null },
events: {} as
| { type: 'CHANGE'; value: string }
| { type: 'BLUR' }
| { type: 'VALIDATE' }
},
guards: {
isValid: ({ context }) => context.value.length > 0
}
}).createMachine({
id: 'field',
initial: 'pristine',
context: { value: '', error: null },
states: {
pristine: {
on: {
CHANGE: {
target: 'dirty',
actions: assign({ value: ({ event }) => event.value })
}
}
},
dirty: {
on: {
CHANGE: {
actions: assign({ value: ({ event }) => event.value })
},
BLUR: 'validating'
}
},
validating: {
always: [
{ target: 'valid', guard: 'isValid' },
{
target: 'invalid',
actions: assign({ error: 'Field is required' })
}
]
},
valid: {
on: {
CHANGE: 'dirty'
}
},
invalid: {
on: {
CHANGE: 'dirty'
}
}
}
});
// Form machine composes multiple fields
const formMachine = setup({
actors: {
field: fieldMachine
}
}).createMachine({
id: 'form',
type: 'parallel',
states: {
nameField: {
invoke: {
id: 'name',
src: 'field'
}
},
emailField: {
invoke: {
id: 'email',
src: 'field'
}
},
submission: {
initial: 'idle',
states: {
idle: {
on: {
SUBMIT: {
target: 'submitting',
// Check all fields are valid
guard: ({ context }) => {
// Access child actors to check validity
return true; // Simplified
}
}
}
},
submitting: {},
success: {},
failure: {}
}
}
}
});Spawning Dynamic Actors
import { setup, assign, spawn } from 'xstate';
// Task machine (spawned dynamically)
const taskMachine = setup({
types: {
context: {} as { id: string; title: string; completed: boolean },
input: {} as { id: string; title: string }
}
}).createMachine({
id: 'task',
initial: 'active',
context: ({ input }) => ({
id: input.id,
title: input.title,
completed: false
}),
states: {
active: {
on: {
COMPLETE: {
actions: assign({ completed: true })
}
}
}
}
});
// Todo list machine that spawns tasks
const todoListMachine = setup({
types: {
context: {} as {
tasks: Map<string, ActorRefFrom<typeof taskMachine>>;
nextId: number;
},
events: {} as
| { type: 'ADD_TASK'; title: string }
| { type: 'REMOVE_TASK'; id: string }
| { type: 'COMPLETE_TASK'; id: string }
},
actors: {
task: taskMachine
}
}).createMachine({
id: 'todoList',
initial: 'active',
context: {
tasks: new Map(),
nextId: 0
},
states: {
active: {
on: {
ADD_TASK: {
actions: assign({
tasks: ({ context, event, spawn }) => {
const id = `task-${context.nextId}`;
const taskRef = spawn('task', {
id,
input: { id, title: event.title }
});
const newTasks = new Map(context.tasks);
newTasks.set(id, taskRef);
return newTasks;
},
nextId: ({ context }) => context.nextId + 1
})
},
REMOVE_TASK: {
actions: assign({
tasks: ({ context, event }) => {
const newTasks = new Map(context.tasks);
const taskRef = newTasks.get(event.id);
if (taskRef) {
taskRef.stop();
newTasks.delete(event.id);
}
return newTasks;
}
})
},
COMPLETE_TASK: {
actions: ({ context, event }) => {
const taskRef = context.tasks.get(event.id);
if (taskRef) {
taskRef.send({ type: 'COMPLETE' });
}
}
}
}
}
}
});Higher-Order Machines
Machine Factory Pattern
import { setup, fromPromise } from 'xstate';
function createDataFetcherMachine<T>(config: {
fetchFn: () => Promise<T>;
retryLimit?: number;
cacheKey?: string;
}) {
return setup({
types: {
context: {} as {
data: T | null;
error: Error | null;
retryCount: number;
}
},
actors: {
fetchData: fromPromise(config.fetchFn)
}
}).createMachine({
id: `dataFetcher-${config.cacheKey || 'default'}`,
initial: 'idle',
context: {
data: null,
error: null,
retryCount: 0
},
states: {
idle: {
on: { FETCH: 'loading' }
},
loading: {
invoke: {
src: 'fetchData',
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output })
},
onError: [
{
target: 'retrying',
guard: ({ context }) =>
context.retryCount < (config.retryLimit || 3),
actions: assign({
retryCount: ({ context }) => context.retryCount + 1
})
},
{
target: 'failure',
actions: assign({ error: ({ event }) => event.error })
}
]
}
},
retrying: {
after: { 1000: 'loading' }
},
success: {},
failure: {}
}
});
}
// Usage
const userFetcherMachine = createDataFetcherMachine({
fetchFn: () => fetch('/api/user').then(r => r.json()),
retryLimit: 5,
cacheKey: 'user'
});Receptionist Pattern (systemId)
Global Actor Registry
import { setup } from 'xstate';
// Auth machine
const authMachine = setup({
types: {
context: {} as { user: User | null }
}
}).createMachine({
id: 'auth',
initial: 'unauthenticated',
context: { user: null },
states: {
unauthenticated: {},
authenticated: {}
}
});
// App machine with systemId
const appMachine = setup({
actors: {
auth: authMachine
}
}).createMachine({
id: 'app',
type: 'parallel',
states: {
auth: {
invoke: {
src: 'auth',
systemId: 'auth' // Global ID
}
},
dashboard: {
entry: ({ system }) => {
const authActor = system.get('auth');
console.log('Auth state:', authActor.getSnapshot());
}
},
notifications: {
entry: ({ system }) => {
const authActor = system.get('auth');
authActor.subscribe((snapshot) => {
console.log('Auth changed:', snapshot.context.user);
});
}
}
}
});React Integration
Accessing Child Actors
import { useSelector } from '@xstate/react';
function ParentComponent() {
const actorRef = useActorRef(parentMachine);
const childSnapshot = useSelector(actorRef, (snapshot) => {
const childRef = snapshot.children.get('childActor');
return childRef?.getSnapshot();
});
return (
<div>
<h2>Parent</h2>
{childSnapshot && (
<div>Child: {JSON.stringify(childSnapshot.value)}</div>
)}
</div>
);
}Context Provider for Composed Machines
import { createActorContext } from '@xstate/react';
const RootMachineContext = createActorContext(rootMachine);
function App() {
return (
<RootMachineContext.Provider>
<Dashboard />
<Notifications />
</RootMachineContext.Provider>
);
}
function Dashboard() {
const actorRef = RootMachineContext.useActorRef();
const { system } = actorRef;
const authActor = system.get('auth');
const user = useSelector(authActor, (s) => s.context.user);
return <div>Welcome, {user?.name}</div>;
}Best Practices
Composition Checklist
✅ Use sendTo/sendParent for explicit communication ✅ Spawn actors dynamically for variable-length lists ✅ Use systemId for global actor registry ✅ Create machine factories for reusable patterns ✅ Compose with parallel states for orthogonal features ✅ Keep machines focused - single responsibility ✅ Document communication patterns in comments ✅ Test composed machines in isolation first ✅ Use TypeScript for type-safe actor references ✅ Visualize composition with Stately tools
When to Compose vs. When to Split
| Scenario | Approach |
|---|---|
| Features share state | Compose in parent machine |
| Features are independent | Separate machines, use systemId |
| Dynamic list of items | Spawn actors |
| Reusable behavior | Machine factory or mixin |
| Cross-cutting concerns | Higher-order machine |
| Tight coupling needed | Parent-child with sendTo |
| Loose coupling preferred | Event bus or systemId |
Decision Trees for State Machine Adoption
When to Use State Machines vs Alternatives
Decision Tree: State Management Strategy
Do you have UI behavior with distinct modes?
├─ NO → Use useState for simple values
│ Use useReducer for related state updates
│
└─ YES → Do you have 3+ boolean flags to track mode?
├─ NO → Is there async coordination between states?
│ ├─ NO → useReducer is sufficient
│ └─ YES → Consider state machine
│
└─ YES → Do you write complex conditionals like:
if (isLoading && !isError && data && !isRefreshing)?
├─ NO → useReducer might work
└─ YES → **Use state machine** ✓Concrete Examples
✅ Use State Machine When:
1. Boolean Flag Explosion
// ANTI-PATTERN: Boolean soup
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
// Impossible states are possible:
// isLoading=true, isSuccess=true, isError=true 🤯2. Complex State Transitions
// ANTI-PATTERN: Implicit state machine
if (status === 'idle' && !error) {
// Can transition to loading
} else if (status === 'loading' && retryCount < 3) {
// Can retry
} else if (status === 'success' && !isStale) {
// Can refresh
}
// Hard to reason about, easy to introduce bugs3. Timing Coordination
// ANTI-PATTERN: Manual timeout management
useEffect(() => {
if (showModal) {
const timer = setTimeout(() => setIsAnimating(false), 300);
return () => clearTimeout(timer);
}
}, [showModal]);
// State machine handles this declaratively4. Multi-Step Workflows
- Wizards, onboarding flows, checkout processes
- Each step has validation, can go back/forward
- Need to track progress and prevent invalid jumps
❌ Don't Use State Machine When:
1. Simple Toggle
// GOOD: Just use useState
const [isOpen, setIsOpen] = useState(false);
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>2. Independent Form Fields
// GOOD: useReducer or react-hook-form
const [formData, setFormData] = useReducer(formReducer, initialState);
// No complex state transitions, just data updates3. Server State Caching
// GOOD: Use React Query / TanStack Query
const { data, isLoading, error } = useQuery(['users'], fetchUsers);
// React Query handles caching, refetching, background updates4. Simple Derived State
// GOOD: Just compute it
const [count, setCount] = useState(0);
const isEven = count % 2 === 0; // No state machine neededWhen to Split Machines
Decision Tree: Machine Granularity
Is your machine definition > 200 lines?
├─ NO → Keep as single machine
│
└─ YES → Do you have parallel concerns?
├─ YES → Split into parallel states or separate machines
│ Example: playback + volume + fullscreen
│
└─ NO → Do substates share no common transitions?
├─ YES → Extract to separate machines
│ Example: auth machine + profile machine
│
└─ NO → Use hierarchical states
Example: form.editing.step1, form.editing.step2Splitting Strategies
1. Parallel States (Orthogonal Concerns)
// BEFORE: Flat state explosion
states: {
playingMuted, playingUnmuted, pausedMuted, pausedUnmuted,
playingFullscreen, pausedFullscreen, // ... 12+ states
}
// AFTER: Parallel states
states: {
ready: {
type: 'parallel',
states: {
playback: { initial: 'paused', states: { playing, paused } },
volume: { initial: 'unmuted', states: { muted, unmuted } },
fullscreen: { initial: 'windowed', states: { windowed, fullscreen } }
}
}
}2. Separate Machines (Independent Lifecycles)
// BEFORE: Monolithic app machine
const appMachine = createMachine({
states: {
authenticating, authenticated, // Auth logic
loadingProfile, profileLoaded, // Profile logic
fetchingPosts, postsLoaded, // Posts logic
}
});
// AFTER: Composed machines
const authMachine = createMachine({ /* auth only */ });
const profileMachine = createMachine({ /* profile only */ });
const postsMachine = createMachine({ /* posts only */ });
// Coordinate via parent
const appMachine = createMachine({
invoke: [
{ src: authMachine, systemId: 'auth' },
{ src: profileMachine, systemId: 'profile' },
{ src: postsMachine, systemId: 'posts' }
]
});3. Hierarchical States (Shared Transitions)
// GOOD: Nested states for shared behavior
const editorMachine = createMachine({
states: {
editing: {
// All substates can SAVE or CANCEL
on: { SAVE: 'saving', CANCEL: 'idle' },
initial: 'text',
states: {
text: { on: { FORMAT: 'formatting' } },
formatting: { on: { DONE: 'text' } }
}
},
saving: { /* ... */ }
}
});Performance Considerations
Decision Tree: Optimization Strategy
Is your component re-rendering too often?
├─ NO → No optimization needed
│
└─ YES → Are you using useMachine?
├─ YES → Switch to useActorRef + useSelector
│ Only subscribe to needed values
│
└─ NO → Are your selectors recreated each render?
├─ YES → Move selectors outside component
│ Or memoize with useCallback
│
└─ NO → Are you selecting complex objects?
├─ YES → Use comparison function (shallowEqual)
│ Or select primitive values only
│
└─ NO → Profile with React DevTools
May be unrelated to state machinePerformance Patterns
Pattern 1: Selective Subscriptions
// SLOW: Re-renders on every state change
const [snapshot, send] = useMachine(complexMachine);
// FAST: Only re-renders when count changes
const actorRef = useActorRef(complexMachine);
const count = useSelector(actorRef, s => s.context.count);Pattern 2: Memoized Selectors
// SLOW: New function every render
const count = useSelector(actorRef, (s) => s.context.count);
// FAST: Stable reference
const selectCount = useCallback((s) => s.context.count, []);
const count = useSelector(actorRef, selectCount);
// BEST: Define outside component
const selectCount = (s) => s.context.count;
function Component() {
const count = useSelector(actorRef, selectCount);
}Summary Cheat Sheet
| Scenario | Solution |
|---|---|
| Simple toggle | useState |
| Related state updates | useReducer |
| 3+ boolean flags for mode | State machine |
| Complex async coordination | State machine |
| Multi-step workflow | State machine |
| Server state caching | React Query |
| Independent form fields | useReducer or react-hook-form |
| Parallel concerns | Parallel states |
| Independent lifecycles | Separate machines |
| Shared transitions | Hierarchical states |
| Too many re-renders | useActorRef + useSelector |
| Complex object selection | Comparison function |
Error Handling and Recovery Patterns
Error Boundaries Integration
Error Boundary with State Machine
import { Component, ErrorInfo, ReactNode } from 'react';
import { createActor } from 'xstate';
import { errorBoundaryMachine } from './errorBoundaryMachine';
interface Props {
children: ReactNode;
fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class StateMachineErrorBoundary extends Component<Props, State> {
private actor = createActor(errorBoundaryMachine);
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
componentDidMount() {
this.actor.start();
this.actor.subscribe((snapshot) => {
this.setState({
hasError: snapshot.matches('error'),
error: snapshot.context.error
});
});
}
componentWillUnmount() {
this.actor.stop();
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.actor.send({ type: 'ERROR_CAUGHT', error, errorInfo });
}
handleReset = () => {
this.actor.send({ type: 'RESET' });
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError && this.state.error) {
return this.props.fallback?.(this.state.error, this.handleReset) || (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error.message}</pre>
</details>
<button onClick={this.handleReset}>Try again</button>
</div>
);
}
return this.props.children;
}
}Error Boundary Machine
import { setup, assign } from 'xstate';
interface ErrorContext {
error: Error | null;
errorInfo: any;
errorCount: number;
lastErrorTime: number | null;
}
type ErrorEvent =
| { type: 'ERROR_CAUGHT'; error: Error; errorInfo: any }
| { type: 'RESET' }
| { type: 'REPORT_ERROR' };
export const errorBoundaryMachine = setup({
types: {
context: {} as ErrorContext,
events: {} as ErrorEvent
},
actions: {
captureError: assign({
error: ({ event }) => event.error,
errorInfo: ({ event }) => event.errorInfo,
errorCount: ({ context }) => context.errorCount + 1,
lastErrorTime: () => Date.now()
}),
resetError: assign({
error: null,
errorInfo: null
}),
reportError: ({ context }) => {
// Send to error tracking service
if (context.error) {
console.error('Error reported:', context.error, context.errorInfo);
// Example: Sentry.captureException(context.error);
}
}
},
guards: {
isCriticalError: ({ context }) => {
// Too many errors in short time = critical
const fiveMinutes = 5 * 60 * 1000;
const recentErrors = context.errorCount > 3;
const withinTimeWindow = context.lastErrorTime
? Date.now() - context.lastErrorTime < fiveMinutes
: false;
return recentErrors && withinTimeWindow;
}
}
}).createMachine({
id: 'errorBoundary',
initial: 'idle',
context: {
error: null,
errorInfo: null,
errorCount: 0,
lastErrorTime: null
},
states: {
idle: {
on: {
ERROR_CAUGHT: {
target: 'error',
actions: ['captureError', 'reportError']
}
}
},
error: {
always: [
{
target: 'critical',
guard: 'isCriticalError'
}
],
on: {
RESET: {
target: 'idle',
actions: 'resetError'
},
ERROR_CAUGHT: {
actions: ['captureError', 'reportError']
}
}
},
critical: {
// Prevent further resets - requires page reload
entry: () => {
console.error('Critical error state - too many errors');
}
}
}
});Retry Strategies
Exponential Backoff
import { setup, assign, fromPromise } from 'xstate';
interface RetryContext {
data: any;
error: Error | null;
retryCount: number;
maxRetries: number;
baseDelay: number;
}
export const exponentialBackoffMachine = setup({
types: {
context: {} as RetryContext,
events: {} as
| { type: 'FETCH' }
| { type: 'RETRY' }
| { type: 'CANCEL' }
},
actors: {
fetchData: fromPromise(async ({ input }) => {
const response = await fetch(input.url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
})
},
actions: {
incrementRetry: assign({
retryCount: ({ context }) => context.retryCount + 1
}),
setData: assign({
data: ({ event }) => event.output,
error: null,
retryCount: 0
}),
setError: assign({
error: ({ event }) => event.error
})
},
guards: {
canRetry: ({ context }) => context.retryCount < context.maxRetries,
shouldRetry: ({ context, event }) => {
// Don't retry on client errors (4xx)
const error = event.error as any;
if (error.message?.includes('HTTP 4')) return false;
return context.retryCount < context.maxRetries;
}
},
delays: {
exponentialDelay: ({ context }) => {
// 2^retryCount * baseDelay with jitter
const exponential = Math.pow(2, context.retryCount) * context.baseDelay;
const jitter = Math.random() * 1000; // Add randomness
return Math.min(exponential + jitter, 30000); // Max 30s
}
}
}).createMachine({
id: 'exponentialBackoff',
initial: 'idle',
context: {
data: null,
error: null,
retryCount: 0,
maxRetries: 5,
baseDelay: 1000
},
states: {
idle: {
on: {
FETCH: 'loading'
}
},
loading: {
invoke: {
src: 'fetchData',
input: { url: '/api/data' },
onDone: {
target: 'success',
actions: 'setData'
},
onError: [
{
target: 'retrying',
guard: 'shouldRetry',
actions: ['setError', 'incrementRetry']
},
{
target: 'failure',
actions: 'setError'
}
]
},
on: {
CANCEL: 'idle'
}
},
retrying: {
after: {
exponentialDelay: 'loading'
},
on: {
CANCEL: 'idle'
}
},
success: {
on: {
FETCH: 'loading'
}
},
failure: {
on: {
RETRY: {
target: 'loading',
actions: assign({ retryCount: 0 })
}
}
}
}
});Circuit Breaker Pattern
import { setup, assign, fromPromise } from 'xstate';
interface CircuitBreakerContext {
failureCount: number;
successCount: number;
lastFailureTime: number | null;
threshold: number;
timeout: number;
data: any;
error: Error | null;
}
export const circuitBreakerMachine = setup({
types: {
context: {} as CircuitBreakerContext,
events: {} as
| { type: 'CALL' }
| { type: 'RESET' }
},
actors: {
makeRequest: fromPromise(async ({ input }) => {
const response = await fetch(input.url);
if (!response.ok) throw new Error('Request failed');
return response.json();
})
},
actions: {
incrementFailure: assign({
failureCount: ({ context }) => context.failureCount + 1,
lastFailureTime: () => Date.now()
}),
incrementSuccess: assign({
successCount: ({ context }) => context.successCount + 1,
failureCount: 0
}),
setData: assign({
data: ({ event }) => event.output,
error: null
}),
setError: assign({
error: ({ event }) => event.error
}),
resetCircuit: assign({
failureCount: 0,
successCount: 0,
lastFailureTime: null,
error: null
})
},
guards: {
thresholdReached: ({ context }) => {
return context.failureCount >= context.threshold;
},
timeoutElapsed: ({ context }) => {
if (!context.lastFailureTime) return false;
return Date.now() - context.lastFailureTime >= context.timeout;
},
successThresholdReached: ({ context }) => {
return context.successCount >= 3;
}
}
}).createMachine({
id: 'circuitBreaker',
initial: 'closed',
context: {
failureCount: 0,
successCount: 0,
lastFailureTime: null,
threshold: 5,
timeout: 60000,
data: null,
error: null
},
states: {
closed: {
on: {
CALL: 'calling'
}
},
calling: {
invoke: {
src: 'makeRequest',
input: { url: '/api/data' },
onDone: {
target: 'closed',
actions: ['setData', 'incrementSuccess']
},
onError: [
{
target: 'open',
guard: 'thresholdReached',
actions: ['setError', 'incrementFailure']
},
{
target: 'closed',
actions: ['setError', 'incrementFailure']
}
]
}
},
open: {
entry: () => console.warn('Circuit breaker OPEN'),
after: {
timeout: 'halfOpen'
},
on: {
CALL: {
actions: () => {
throw new Error('Circuit breaker is OPEN');
}
},
RESET: {
target: 'closed',
actions: 'resetCircuit'
}
}
},
halfOpen: {
on: {
CALL: 'testing'
}
},
testing: {
invoke: {
src: 'makeRequest',
input: { url: '/api/data' },
onDone: [
{
target: 'closed',
guard: 'successThresholdReached',
actions: ['setData', 'incrementSuccess', 'resetCircuit']
},
{
target: 'halfOpen',
actions: ['setData', 'incrementSuccess']
}
],
onError: {
target: 'open',
actions: ['setError', 'incrementFailure']
}
}
}
}
});Graceful Degradation
Feature Fallback Machine
import { setup, assign, fromPromise } from 'xstate';
interface FeatureContext {
primaryAvailable: boolean;
fallbackAvailable: boolean;
data: any;
error: Error | null;
mode: 'primary' | 'fallback' | 'offline';
}
export const gracefulDegradationMachine = setup({
types: {
context: {} as FeatureContext,
events: {} as
| { type: 'LOAD' }
| { type: 'RETRY_PRIMARY' }
},
actors: {
loadPrimary: fromPromise(async () => {
const response = await fetch('/api/v2/data');
if (!response.ok) throw new Error('Primary API failed');
return response.json();
}),
loadFallback: fromPromise(async () => {
const response = await fetch('/api/v1/data');
if (!response.ok) throw new Error('Fallback API failed');
return response.json();
}),
loadOffline: fromPromise(async () => {
const cached = localStorage.getItem('cached_data');
if (!cached) throw new Error('No cached data');
return JSON.parse(cached);
})
},
actions: {
setPrimaryData: assign({
data: ({ event }) => event.output,
primaryAvailable: true,
mode: 'primary',
error: null
}),
setFallbackData: assign({
data: ({ event }) => event.output,
fallbackAvailable: true,
mode: 'fallback',
error: null
}),
setOfflineData: assign({
data: ({ event }) => event.output,
mode: 'offline',
error: null
}),
setError: assign({
error: ({ event }) => event.error
})
}
}).createMachine({
id: 'gracefulDegradation',
initial: 'loading',
context: {
primaryAvailable: false,
fallbackAvailable: false,
data: null,
error: null,
mode: 'primary'
},
states: {
loading: {
invoke: {
src: 'loadPrimary',
onDone: {
target: 'success',
actions: 'setPrimaryData'
},
onError: 'tryingFallback'
}
},
tryingFallback: {
invoke: {
src: 'loadFallback',
onDone: {
target: 'success',
actions: 'setFallbackData'
},
onError: 'tryingOffline'
}
},
tryingOffline: {
invoke: {
src: 'loadOffline',
onDone: {
target: 'success',
actions: 'setOfflineData'
},
onError: {
target: 'failure',
actions: 'setError'
}
}
},
success: {
on: {
RETRY_PRIMARY: {
target: 'loading',
guard: ({ context }) => context.mode !== 'primary'
}
}
},
failure: {
on: {
RETRY_PRIMARY: 'loading'
}
}
}
});React Component with Degradation UI
import { useMachine } from '@xstate/react';
import { gracefulDegradationMachine } from './gracefulDegradationMachine';
export function DataDisplay() {
const [snapshot, send] = useMachine(gracefulDegradationMachine);
const { data, mode, error } = snapshot.context;
return (
<div>
{mode === 'fallback' && (
<div className="warning">
Using legacy API. Some features may be limited.
<button onClick={() => send({ type: 'RETRY_PRIMARY' })}>
Retry
</button>
</div>
)}
{mode === 'offline' && (
<div className="warning">
Offline mode. Showing cached data.
<button onClick={() => send({ type: 'RETRY_PRIMARY' })}>
Reconnect
</button>
</div>
)}
{snapshot.matches('success') && data && (
<div className="data-display">
{mode === 'primary' && <AdvancedFeatures data={data} />}
{mode === 'fallback' && <BasicFeatures data={data} />}
{mode === 'offline' && <ReadOnlyView data={data} />}
</div>
)}
{snapshot.matches('failure') && (
<div className="error">
<p>Unable to load data: {error?.message}</p>
<button onClick={() => send({ type: 'RETRY_PRIMARY' })}>
Retry
</button>
</div>
)}
</div>
);
}Best Practices Summary
Error Handling Checklist
✅ Use error boundaries for component-level error isolation ✅ Implement retry logic with exponential backoff for transient failures ✅ Add circuit breakers for failing external services ✅ Provide fallbacks for degraded functionality ✅ Log errors to monitoring services (Sentry, LogRocket, etc.) ✅ Show user-friendly messages instead of technical errors ✅ Allow manual retry when automatic retry fails ✅ Cache data for offline scenarios ✅ Test error states as thoroughly as success states ✅ Monitor error rates to detect systemic issues
When to Use Each Pattern
| Pattern | Use Case |
|---|---|
| Error Boundary | Isolate component failures, prevent full app crash |
| Exponential Backoff | Transient network errors, rate limiting |
| Circuit Breaker | Protect against cascading failures, failing services |
| Graceful Degradation | Provide reduced functionality when primary fails |
| Retry with Jitter | Prevent thundering herd problem |
| Timeout | Prevent indefinite waiting |
Migration Guide: From React Hooks to State Machines
From useState to State Machines
Example 1: Simple Toggle
Before (useState)
function ToggleButton() {
const [isOn, setIsOn] = useState(false);
return (
<button onClick={() => setIsOn(!isOn)}>
{isOn ? 'ON' : 'OFF'}
</button>
);
}After (State Machine)
import { setup } from 'xstate';
import { useMachine } from '@xstate/react';
const toggleMachine = setup({
types: {
events: {} as { type: 'TOGGLE' }
}
}).createMachine({
id: 'toggle',
initial: 'off',
states: {
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } }
}
});
function ToggleButton() {
const [snapshot, send] = useMachine(toggleMachine);
return (
<button onClick={() => send({ type: 'TOGGLE' })}>
{snapshot.matches('on') ? 'ON' : 'OFF'}
</button>
);
}When to migrate: When you have boolean flags that represent distinct states.
Example 2: Boolean Flag Explosion
Before (useState) - The Problem
function DataFetcher() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [isRetrying, setIsRetrying] = useState(false);
// ❌ Impossible states possible:
// isLoading=true AND error=true
// isLoading=false AND data=null AND error=null
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const result = await fetch('/api/data');
setData(result);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
return (
<div>
{isLoading && <div>Loading...</div>}
{error && <div>Error: {error.message}</div>}
{data && <div>{data}</div>}
</div>
);
}After (State Machine) - Impossible States Eliminated
import { setup, fromPromise } from 'xstate';
import { useMachine } from '@xstate/react';
const dataFetcherMachine = setup({
types: {
context: {} as { data: any; error: Error | null },
events: {} as { type: 'FETCH' } | { type: 'RETRY' }
},
actors: {
fetchData: fromPromise(async () => {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
})
}
}).createMachine({
id: 'dataFetcher',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' }
},
loading: {
invoke: {
src: 'fetchData',
onDone: {
target: 'success',
actions: assign({ data: ({ event }) => event.output })
},
onError: {
target: 'failure',
actions: assign({ error: ({ event }) => event.error })
}
}
},
success: {
on: { FETCH: 'loading' }
},
failure: {
on: { RETRY: 'loading' }
}
}
});
function DataFetcher() {
const [snapshot, send] = useMachine(dataFetcherMachine);
return (
<div>
{snapshot.matches('loading') && <div>Loading...</div>}
{snapshot.matches('failure') && (
<div>
Error: {snapshot.context.error?.message}
<button onClick={() => send({ type: 'RETRY' })}>Retry</button>
</div>
)}
{snapshot.matches('success') && <div>{snapshot.context.data}</div>}
</div>
);
}When to migrate: When you have 3+ boolean flags representing state.
Example 3: Complex State Transitions
Before (useState) - Hard to Reason About
function FormWizard() {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const goNext = () => {
// ❌ Complex validation logic scattered
if (step === 1 && !formData.name) {
setErrors({ name: 'Required' });
return;
}
if (step === 2 && !formData.email) {
setErrors({ email: 'Required' });
return;
}
setStep(step + 1);
};
const goBack = () => {
if (step > 1) setStep(step - 1);
};
// ❌ Can accidentally set step to invalid value
// setStep(99) - no protection
}After (State Machine) - Explicit Transitions
const wizardMachine = setup({
types: {
context: {} as { formData: any; errors: any },
events: {} as
| { type: 'NEXT' }
| { type: 'PREVIOUS' }
| { type: 'UPDATE'; field: string; value: any }
},
guards: {
isStep1Valid: ({ context }) => !!context.formData.name,
isStep2Valid: ({ context }) => !!context.formData.email
}
}).createMachine({
id: 'wizard',
initial: 'step1',
context: { formData: {}, errors: {} },
states: {
step1: {
on: {
NEXT: {
target: 'step2',
guard: 'isStep1Valid'
},
UPDATE: {
actions: assign({
formData: ({ context, event }) => ({
...context.formData,
[event.field]: event.value
})
})
}
}
},
step2: {
on: {
NEXT: {
target: 'step3',
guard: 'isStep2Valid'
},
PREVIOUS: 'step1'
}
},
step3: {
on: {
PREVIOUS: 'step2'
}
}
}
});When to migrate: When you have sequential states with validation.
From useReducer to State Machines
Example: Authentication Flow
Before (useReducer)
type State = {
status: 'idle' | 'loading' | 'authenticated' | 'error';
user: User | null;
error: Error | null;
};
type Action =
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS'; user: User }
| { type: 'LOGIN_FAILURE'; error: Error }
| { type: 'LOGOUT' };
function authReducer(state: State, action: Action): State {
switch (action.type) {
case 'LOGIN_START':
return { ...state, status: 'loading', error: null };
case 'LOGIN_SUCCESS':
return { status: 'authenticated', user: action.user, error: null };
case 'LOGIN_FAILURE':
return { ...state, status: 'error', error: action.error };
case 'LOGOUT':
return { status: 'idle', user: null, error: null };
default:
return state;
}
}
function useAuth() {
const [state, dispatch] = useReducer(authReducer, {
status: 'idle',
user: null,
error: null
});
const login = async (credentials) => {
dispatch({ type: 'LOGIN_START' });
try {
const user = await api.login(credentials);
dispatch({ type: 'LOGIN_SUCCESS', user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE', error });
}
};
return { state, login };
}After (State Machine)
import { setup, fromPromise, assign } from 'xstate';
import { useMachine } from '@xstate/react';
const authMachine = setup({
types: {
context: {} as { user: User | null; error: Error | null },
events: {} as
| { type: 'LOGIN'; credentials: Credentials }
| { type: 'LOGOUT' }
},
actors: {
loginUser: fromPromise(async ({ input }) => {
return await api.login(input.credentials);
})
}
}).createMachine({
id: 'auth',
initial: 'idle',
context: { user: null, error: null },
states: {
idle: {
on: {
LOGIN: 'authenticating'
}
},
authenticating: {
invoke: {
src: 'loginUser',
input: ({ event }) => ({ credentials: event.credentials }),
onDone: {
target: 'authenticated',
actions: assign({ user: ({ event }) => event.output })
},
onError: {
target: 'idle',
actions: assign({ error: ({ event }) => event.error })
}
}
},
authenticated: {
on: {
LOGOUT: {
target: 'idle',
actions: assign({ user: null })
}
}
}
}
});
function useAuth() {
const [snapshot, send] = useMachine(authMachine);
const login = (credentials: Credentials) => {
send({ type: 'LOGIN', credentials });
};
const logout = () => {
send({ type: 'LOGOUT' });
};
return {
isAuthenticated: snapshot.matches('authenticated'),
isLoading: snapshot.matches('authenticating'),
user: snapshot.context.user,
error: snapshot.context.error,
login,
logout
};
}Benefits:
- ✅ Async logic handled by machine (no manual try/catch)
- ✅ Impossible to be in
loadingandauthenticatedsimultaneously - ✅ Clear state transitions
- ✅ Built-in side effect management
Migration Strategy
Step-by-Step Process
1. Identify State Patterns
Look for these patterns in your code:
- Multiple
useStatecalls for related state - Boolean flags that represent states (
isLoading,isError,isSuccess) - Complex
useEffectdependencies - State that depends on other state
- Sequential workflows (wizards, multi-step forms)
2. Map States and Events
// Current useState code
const [isLoading, setIsLoading] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
// Map to states
// States: idle, loading, success, error
// Map events
// Events: FETCH, RETRY, RESET3. Create Machine Definition
const machine = setup({
types: {
events: {} as
| { type: 'FETCH' }
| { type: 'RETRY' }
| { type: 'RESET' }
}
}).createMachine({
initial: 'idle',
states: {
idle: { on: { FETCH: 'loading' } },
loading: { /* ... */ },
success: { on: { RESET: 'idle' } },
error: { on: { RETRY: 'loading' } }
}
});4. Migrate Incrementally
Don't rewrite everything at once. Start with one component:
// Phase 1: Keep old code, add machine alongside
function Component() {
// Old code (keep working)
const [isLoading, setIsLoading] = useState(false);
// New machine (test in parallel)
const [snapshot, send] = useMachine(newMachine);
// Use old code in UI for now
return <div>{isLoading ? 'Loading...' : 'Ready'}</div>;
}
// Phase 2: Switch UI to machine, keep old code
function Component() {
const [isLoading, setIsLoading] = useState(false);
const [snapshot, send] = useMachine(newMachine);
// Use machine in UI
return <div>{snapshot.matches('loading') ? 'Loading...' : 'Ready'}</div>;
}
// Phase 3: Remove old code
function Component() {
const [snapshot, send] = useMachine(newMachine);
return <div>{snapshot.matches('loading') ? 'Loading...' : 'Ready'}</div>;
}5. Test Thoroughly
import { createActor } from 'xstate';
import { describe, it, expect } from 'vitest';
describe('Migration to state machine', () => {
it('should handle same scenarios as old code', () => {
const actor = createActor(newMachine);
actor.start();
// Test old behavior
expect(actor.getSnapshot().matches('idle')).toBe(true);
actor.send({ type: 'FETCH' });
expect(actor.getSnapshot().matches('loading')).toBe(true);
});
});Common Migration Pitfalls
Pitfall 1: Over-Engineering Simple State
// ❌ DON'T: Use state machine for simple boolean
const toggleMachine = setup({...}).createMachine({
initial: 'off',
states: {
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } }
}
});
// ✅ DO: Use useState for simple cases
const [isOn, setIsOn] = useState(false);Rule: If you only have 2 states and 1 event, useState is fine.
Pitfall 2: Not Using Actors for Async
// ❌ DON'T: Manual async in actions
const machine = setup({
actions: {
fetchData: async ({ context }) => {
const data = await fetch('/api'); // Won't work!
// Actions can't be async
}
}
});
// ✅ DO: Use invoke with fromPromise
const machine = setup({
actors: {
fetchData: fromPromise(async () => {
return await fetch('/api').then(r => r.json());
})
}
}).createMachine({
states: {
loading: {
invoke: {
src: 'fetchData',
onDone: 'success',
onError: 'failure'
}
}
}
});Pitfall 3: Forgetting to Handle All States in UI
// ❌ DON'T: Forget states
function Component() {
const [snapshot, send] = useMachine(machine);
if (snapshot.matches('success')) {
return <div>Success!</div>;
}
// What about loading? error? idle?
return null; // ❌ Incomplete
}
// ✅ DO: Handle all states
function Component() {
const [snapshot, send] = useMachine(machine);
if (snapshot.matches('loading')) return <Spinner />;
if (snapshot.matches('error')) return <Error />;
if (snapshot.matches('success')) return <Success />;
return <Idle />; // Default state
}Migration Checklist
Before Migration
- [ ] Identify components with complex state logic
- [ ] Map out current states and transitions
- [ ] List all events that trigger state changes
- [ ] Document current behavior (write tests if needed)
- [ ] Choose one component to start with
During Migration
- [ ] Create machine definition
- [ ] Add TypeScript types for context and events
- [ ] Implement guards for conditional transitions
- [ ] Use actors for async operations
- [ ] Test machine in isolation
- [ ] Integrate with React component
- [ ] Update UI to handle all states
- [ ] Test edge cases
After Migration
- [ ] Remove old useState/useReducer code
- [ ] Update tests
- [ ] Document state machine (visualize with Stately)
- [ ] Monitor for regressions
- [ ] Share learnings with team
Resources
Performance Optimization Patterns
Selector Memoization
Problem: Recreating Selectors Every Render
// ❌ BAD: New function every render
function Component() {
const actorRef = useActorRef(machine);
const count = useSelector(actorRef, (s) => s.context.count); // New function!
// Component re-renders even if count hasn't changed
}Solution 1: Define Selectors Outside Component
// ✅ GOOD: Stable selector reference
const selectCount = (s) => s.context.count;
const selectUser = (s) => s.context.user;
const selectIsLoading = (s) => s.matches('loading');
function Component() {
const actorRef = useActorRef(machine);
const count = useSelector(actorRef, selectCount);
const user = useSelector(actorRef, selectUser);
const isLoading = useSelector(actorRef, selectIsLoading);
}Solution 2: useCallback for Dynamic Selectors
function Component({ userId }: { userId: string }) {
const actorRef = useActorRef(machine);
// Memoize selector with dependencies
const selectUserById = useCallback(
(s) => s.context.users.find(u => u.id === userId),
[userId]
);
const user = useSelector(actorRef, selectUserById);
}Solution 3: Selector Factory Pattern
// Create selector factory
const createUserSelector = (userId: string) => (s) =>
s.context.users.find(u => u.id === userId);
function Component({ userId }: { userId: string }) {
const actorRef = useActorRef(machine);
const selectUser = useMemo(() => createUserSelector(userId), [userId]);
const user = useSelector(actorRef, selectUser);
}Complex Object Selection
Problem: Reference Equality for Objects/Arrays
// ❌ BAD: New array reference every time
const selectTodos = (s) => s.context.todos.filter(t => !t.completed);
function TodoList() {
const actorRef = useActorRef(machine);
const todos = useSelector(actorRef, selectTodos);
// Re-renders even if todos haven't changed!
}Solution: Use Comparison Function
import { useSelector } from '@xstate/react';
// Shallow equality comparison
function shallowEqual<T>(a: T, b: T): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => a[key] === b[key]);
}
// Array shallow equality
function arrayShallowEqual<T>(a: T[], b: T[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((item, index) => item === b[index]);
}
// Usage
const selectTodos = (s) => s.context.todos;
function TodoList() {
const actorRef = useActorRef(machine);
const todos = useSelector(actorRef, selectTodos, arrayShallowEqual);
// Only re-renders when todos array actually changes
}Solution: Select Primitive Values
// ✅ BETTER: Select only what you need
const selectTodoCount = (s) => s.context.todos.length;
const selectCompletedCount = (s) => s.context.todos.filter(t => t.completed).length;
function TodoStats() {
const actorRef = useActorRef(machine);
const total = useSelector(actorRef, selectTodoCount);
const completed = useSelector(actorRef, selectCompletedCount);
return <div>{completed} / {total} completed</div>;
}React.memo Integration
Memoizing Components with State Machine Props
import { memo } from 'react';
import { useSelector } from '@xstate/react';
// Memoized child component
const TodoItem = memo(({
todo,
onToggle,
onDelete
}: {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) => {
console.log('TodoItem render:', todo.id);
return (
<div className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</div>
);
});
// Parent component
function TodoList() {
const actorRef = useActorRef(todoMachine);
const todos = useSelector(actorRef, s => s.context.todos, arrayShallowEqual);
// Memoize callbacks to prevent child re-renders
const handleToggle = useCallback((id: string) => {
actorRef.send({ type: 'TOGGLE_TODO', id });
}, [actorRef]);
const handleDelete = useCallback((id: string) => {
actorRef.send({ type: 'DELETE_TODO', id });
}, [actorRef]);
return (
<div>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={handleToggle}
onDelete={handleDelete}
/>
))}
</div>
);
}Type Narrowing with Guards
Discriminated Unions for Events
// ✅ GOOD: Discriminated union with type narrowing
type TodoEvent =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: string }
| { type: 'DELETE_TODO'; id: string }
| { type: 'UPDATE_TODO'; id: string; text: string };
const todoMachine = setup({
types: {
events: {} as TodoEvent
},
guards: {
hasText: ({ event }) => {
// TypeScript knows event.type could be any TodoEvent
if (event.type === 'ADD_TODO') {
// Now TypeScript knows event has 'text' property
return event.text.length > 0;
}
return false;
}
}
}).createMachine({
// ...
});Branded Types for IDs
// Create branded type for type safety
type TodoId = string & { readonly __brand: 'TodoId' };
type UserId = string & { readonly __brand: 'UserId' };
function createTodoId(id: string): TodoId {
return id as TodoId;
}
function createUserId(id: string): UserId {
return id as UserId;
}
interface TodoContext {
todos: Map<TodoId, Todo>;
currentUserId: UserId | null;
}
// TypeScript prevents mixing up IDs
const todoMachine = setup({
types: {
context: {} as TodoContext
},
guards: {
isTodoOwner: ({ context, event }) => {
const todo = context.todos.get(event.todoId); // TodoId required
return todo?.ownerId === context.currentUserId; // UserId comparison
}
}
});Machine Splitting Strategies
Strategy 1: Extract Independent Concerns
// ❌ BAD: Monolithic machine
const dashboardMachine = createMachine({
context: {
user: null,
notifications: [],
settings: {},
analytics: {},
// ... 20 more fields
},
states: {
// 50+ states handling everything
}
});
// ✅ GOOD: Split by domain
const userMachine = createMachine({
context: { user: null, profile: null },
states: { idle, loading, authenticated }
});
const notificationsMachine = createMachine({
context: { notifications: [], unreadCount: 0 },
states: { idle, fetching, polling }
});
const settingsMachine = createMachine({
context: { theme: 'light', language: 'en' },
states: { idle, saving }
});
// Compose in parent
const dashboardMachine = createMachine({
invoke: [
{ src: userMachine, systemId: 'user' },
{ src: notificationsMachine, systemId: 'notifications' },
{ src: settingsMachine, systemId: 'settings' }
]
});Strategy 2: Use Parallel States for Orthogonal Concerns
// ✅ GOOD: Parallel states for independent features
const editorMachine = createMachine({
type: 'parallel',
states: {
// Document state
document: {
initial: 'clean',
states: {
clean: { on: { EDIT: 'dirty' } },
dirty: { on: { SAVE: 'saving' } },
saving: { on: { SAVED: 'clean' } }
}
},
// Collaboration state (independent)
collaboration: {
initial: 'solo',
states: {
solo: { on: { JOIN: 'connected' } },
connected: { on: { DISCONNECT: 'solo' } }
}
},
// Preview state (independent)
preview: {
initial: 'hidden',
states: {
hidden: { on: { SHOW_PREVIEW: 'visible' } },
visible: { on: { HIDE_PREVIEW: 'hidden' } }
}
}
}
});Strategy 3: Lazy Loading Machines
import { lazy, Suspense } from 'react';
import { useMachine } from '@xstate/react';
// Lazy load heavy machines
const HeavyFeatureMachine = lazy(() =>
import('./heavyFeatureMachine').then(m => ({ default: m.heavyFeatureMachine }))
);
function FeatureComponent() {
const [showFeature, setShowFeature] = useState(false);
return (
<div>
<button onClick={() => setShowFeature(true)}>
Load Feature
</button>
{showFeature && (
<Suspense fallback={<div>Loading...</div>}>
<HeavyFeature />
</Suspense>
)}
</div>
);
}
function HeavyFeature() {
const machine = useMemo(() => createHeavyMachine(), []);
const [snapshot, send] = useMachine(machine);
// ...
}Debouncing and Throttling
Debounced Input with State Machine
const searchMachine = setup({
types: {
context: {} as { query: string; results: any[] },
events: {} as
| { type: 'TYPE'; value: string }
| { type: 'SEARCH' }
},
actors: {
search: fromPromise(async ({ input }) => {
const res = await fetch(`/api/search?q=${input.query}`);
return res.json();
})
}
}).createMachine({
initial: 'idle',
context: { query: '', results: [] },
states: {
idle: {
on: {
TYPE: {
target: 'debouncing',
actions: assign({ query: ({ event }) => event.value })
}
}
},
debouncing: {
after: {
300: 'searching' // Wait 300ms
},
on: {
TYPE: {
target: 'debouncing', // Restart timer
actions: assign({ query: ({ event }) => event.value })
}
}
},
searching: {
invoke: {
src: 'search',
input: ({ context }) => ({ query: context.query }),
onDone: {
target: 'idle',
actions: assign({ results: ({ event }) => event.output })
}
},
on: {
TYPE: {
target: 'debouncing',
actions: assign({ query: ({ event }) => event.value })
}
}
}
}
});Throttled Scroll Events
const scrollMachine = setup({
types: {
context: {} as { scrollY: number; isAtBottom: boolean },
events: {} as { type: 'SCROLL'; scrollY: number }
}
}).createMachine({
initial: 'idle',
context: { scrollY: 0, isAtBottom: false },
states: {
idle: {
on: {
SCROLL: {
target: 'throttling',
actions: assign({
scrollY: ({ event }) => event.scrollY,
isAtBottom: ({ event }) =>
event.scrollY + window.innerHeight >= document.body.scrollHeight - 100
})
}
}
},
throttling: {
after: {
100: 'idle' // Ignore events for 100ms
}
}
}
});Performance Monitoring
Measuring State Machine Performance
import { createActor } from 'xstate';
function createMonitoredActor(machine) {
const actor = createActor(machine);
actor.subscribe({
next: (snapshot) => {
// Log transition time
console.time(`Transition to ${snapshot.value}`);
},
complete: () => {
console.timeEnd('Actor lifecycle');
}
});
// Wrap send to measure event processing
const originalSend = actor.send.bind(actor);
actor.send = (event) => {
const start = performance.now();
originalSend(event);
const duration = performance.now() - start;
if (duration > 16) { // Longer than 1 frame
console.warn(`Slow event processing: ${event.type} took ${duration}ms`);
}
};
return actor;
}Best Practices Summary
Performance Checklist
✅ Define selectors outside components for stable references ✅ Use comparison functions for complex objects/arrays ✅ Select primitive values when possible ✅ Memoize callbacks passed to child components ✅ Use React.memo for expensive child components ✅ Split large machines into smaller, focused ones ✅ Use parallel states for orthogonal concerns ✅ Debounce/throttle high-frequency events ✅ Lazy load heavy machines ✅ Monitor performance in development ✅ Profile with React DevTools to find bottlenecks
Common Performance Pitfalls
| Pitfall | Impact | Solution |
|---|---|---|
| Inline selectors | Re-renders on every state change | Define outside component |
| useMachine for large state | Unnecessary re-renders | Use useActorRef + useSelector |
| No comparison function | Re-renders on object reference change | Use shallowEqual |
| Monolithic machines | Hard to optimize, slow transitions | Split by domain |
| No memoization | Child components re-render unnecessarily | Use React.memo + useCallback |
| Synchronous heavy work | Blocks UI thread | Move to Web Worker or async |
Persistence and Hydration Patterns
LocalStorage Persistence
Basic Persistence Pattern
import { setup, assign, createActor } from 'xstate';
const STORAGE_KEY = 'app_state';
export const persistedMachine = setup({
types: {
context: {} as { count: number; user: User | null },
events: {} as
| { type: 'INCREMENT' }
| { type: 'SET_USER'; user: User }
},
actions: {
persistState: ({ context }) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(context));
} catch (error) {
console.error('Failed to persist state:', error);
}
}
}
}).createMachine({
id: 'persisted',
initial: 'active',
context: { count: 0, user: null },
states: {
active: {
on: {
INCREMENT: {
actions: [
assign({ count: ({ context }) => context.count + 1 }),
'persistState'
]
},
SET_USER: {
actions: [
assign({ user: ({ event }) => event.user }),
'persistState'
]
}
}
}
}
});
// Load persisted state
function loadPersistedState() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : undefined;
} catch (error) {
console.error('Failed to load persisted state:', error);
return undefined;
}
}
// Create actor with persisted state
const actor = createActor(persistedMachine, {
snapshot: loadPersistedState()
});React Hook for Persisted Machine
import { useMachine } from '@xstate/react';
import { useEffect } from 'react';
export function usePersistedMachine(machine, storageKey: string) {
// Load initial state from localStorage
const initialSnapshot = useMemo(() => {
try {
const stored = localStorage.getItem(storageKey);
return stored ? JSON.parse(stored) : undefined;
} catch {
return undefined;
}
}, [storageKey]);
const [snapshot, send, actorRef] = useMachine(machine, {
snapshot: initialSnapshot
});
// Persist on every state change
useEffect(() => {
const subscription = actorRef.subscribe((state) => {
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch (error) {
console.error('Failed to persist:', error);
}
});
return () => subscription.unsubscribe();
}, [actorRef, storageKey]);
return [snapshot, send, actorRef] as const;
}
// Usage
function App() {
const [snapshot, send] = usePersistedMachine(appMachine, 'app_state');
// State automatically persists and restores
}Selective Persistence (Don't Persist Everything)
interface AppContext {
// Persist these
user: User | null;
preferences: Preferences;
// Don't persist these (transient state)
isLoading: boolean;
error: Error | null;
tempData: any;
}
function serializeForPersistence(context: AppContext) {
return {
user: context.user,
preferences: context.preferences
// Omit transient fields
};
}
const persistedMachine = setup({
actions: {
persistState: ({ context }) => {
const serialized = serializeForPersistence(context);
localStorage.setItem(STORAGE_KEY, JSON.stringify(serialized));
}
}
});Versioned Persistence (Handle Schema Changes)
const STORAGE_VERSION = 2;
interface PersistedState {
version: number;
data: any;
timestamp: number;
}
function saveState(context: any) {
const state: PersistedState = {
version: STORAGE_VERSION,
data: context,
timestamp: Date.now()
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function loadState() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return undefined;
const parsed: PersistedState = JSON.parse(stored);
// Check version
if (parsed.version !== STORAGE_VERSION) {
console.warn('State version mismatch, migrating...');
return migrateState(parsed);
}
// Check if stale (older than 7 days)
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - parsed.timestamp > sevenDays) {
console.warn('State is stale, discarding');
return undefined;
}
return parsed.data;
} catch (error) {
console.error('Failed to load state:', error);
return undefined;
}
}
function migrateState(old: PersistedState): any {
// Migrate from v1 to v2
if (old.version === 1) {
return {
...old.data,
newField: 'default value'
};
}
return undefined;
}SSR and Hydration
Next.js Server-Side Rendering
// app/page.tsx (Next.js App Router)
import { createActor } from 'xstate';
import { appMachine } from './machines/appMachine';
export default async function Page() {
// Create actor on server
const actor = createActor(appMachine);
actor.start();
// Fetch initial data
const data = await fetchInitialData();
actor.send({ type: 'SET_DATA', data });
// Get snapshot for client hydration
const snapshot = actor.getSnapshot();
actor.stop();
return (
<ClientComponent initialSnapshot={snapshot} />
);
}// ClientComponent.tsx
'use client';
import { useMachine } from '@xstate/react';
import { appMachine } from './machines/appMachine';
export function ClientComponent({ initialSnapshot }) {
const [snapshot, send] = useMachine(appMachine, {
snapshot: initialSnapshot // Hydrate from server
});
return (
<div>
{/* Component renders with server data immediately */}
<pre>{JSON.stringify(snapshot.context, null, 2)}</pre>
</div>
);
}Next.js Pages Router with getServerSideProps
// pages/dashboard.tsx
import { GetServerSideProps } from 'next';
import { createActor } from 'xstate';
import { dashboardMachine } from '../machines/dashboardMachine';
export const getServerSideProps: GetServerSideProps = async (context) => {
const actor = createActor(dashboardMachine);
actor.start();
// Fetch user data
const user = await fetchUser(context.req.cookies.token);
actor.send({ type: 'SET_USER', user });
// Get serializable snapshot
const snapshot = actor.getSnapshot();
actor.stop();
return {
props: {
initialSnapshot: JSON.parse(JSON.stringify(snapshot))
}
};
};
export default function Dashboard({ initialSnapshot }) {
const [snapshot, send] = useMachine(dashboardMachine, {
snapshot: initialSnapshot
});
return <div>{/* ... */}</div>;
}Handling Non-Serializable Data
// Problem: Functions, Dates, etc. can't be serialized
interface Context {
user: User;
createdAt: Date; // ❌ Not serializable
callback: () => void; // ❌ Not serializable
}
// Solution: Serialize/deserialize with custom logic
function serializeSnapshot(snapshot: any) {
return {
...snapshot,
context: {
...snapshot.context,
createdAt: snapshot.context.createdAt?.toISOString(),
// Omit functions
callback: undefined
}
};
}
function deserializeSnapshot(serialized: any) {
return {
...serialized,
context: {
...serialized.context,
createdAt: serialized.context.createdAt
? new Date(serialized.context.createdAt)
: null,
// Restore functions
callback: () => console.log('Restored callback')
}
};
}
// Server
export const getServerSideProps: GetServerSideProps = async () => {
const actor = createActor(machine);
actor.start();
const snapshot = serializeSnapshot(actor.getSnapshot());
actor.stop();
return { props: { snapshot } };
};
// Client
function Component({ snapshot }) {
const [state, send] = useMachine(machine, {
snapshot: deserializeSnapshot(snapshot)
});
}Preventing Hydration Mismatches
'use client';
import { useMachine } from '@xstate/react';
import { useState, useEffect } from 'react';
export function HydratedComponent({ initialSnapshot }) {
const [isHydrated, setIsHydrated] = useState(false);
const [snapshot, send] = useMachine(machine, {
snapshot: initialSnapshot
});
useEffect(() => {
setIsHydrated(true);
}, []);
// Show server-rendered content until hydrated
if (!isHydrated) {
return <ServerRenderedFallback snapshot={initialSnapshot} />;
}
// Now safe to show client-specific content
return <InteractiveContent snapshot={snapshot} send={send} />;
}Snapshot Serialization
Deep Serialization with Circular References
import { createActor } from 'xstate';
function serializeSnapshot(snapshot: any): string {
const seen = new WeakSet();
return JSON.stringify(snapshot, (key, value) => {
// Handle circular references
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
// Handle special types
if (value instanceof Date) {
return { __type: 'Date', value: value.toISOString() };
}
if (value instanceof Map) {
return {
__type: 'Map',
value: Array.from(value.entries())
};
}
if (value instanceof Set) {
return {
__type: 'Set',
value: Array.from(value)
};
}
return value;
});
}
function deserializeSnapshot(json: string): any {
return JSON.parse(json, (key, value) => {
if (value && typeof value === 'object') {
// Restore special types
if (value.__type === 'Date') {
return new Date(value.value);
}
if (value.__type === 'Map') {
return new Map(value.value);
}
if (value.__type === 'Set') {
return new Set(value.value);
}
}
return value;
});
}Compression for Large States
import pako from 'pako';
function compressSnapshot(snapshot: any): string {
const json = JSON.stringify(snapshot);
const compressed = pako.deflate(json);
return btoa(String.fromCharCode(...compressed));
}
function decompressSnapshot(compressed: string): any {
const binary = atob(compressed);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const decompressed = pako.inflate(bytes, { to: 'string' });
return JSON.parse(decompressed);
}
// Usage
const actor = createActor(machine);
actor.start();
// Save compressed
const compressed = compressSnapshot(actor.getSnapshot());
localStorage.setItem('state', compressed);
// Load compressed
const loaded = decompressSnapshot(localStorage.getItem('state')!);
const restoredActor = createActor(machine, { snapshot: loaded });IndexedDB for Large State
import { openDB, DBSchema } from 'idb';
interface StateDB extends DBSchema {
snapshots: {
key: string;
value: {
id: string;
snapshot: any;
timestamp: number;
};
};
}
class StatePersistence {
private db: Promise<IDBDatabase>;
constructor() {
this.db = openDB<StateDB>('state-db', 1, {
upgrade(db) {
db.createObjectStore('snapshots', { keyPath: 'id' });
}
});
}
async saveSnapshot(id: string, snapshot: any) {
const db = await this.db;
await db.put('snapshots', {
id,
snapshot,
timestamp: Date.now()
});
}
async loadSnapshot(id: string) {
const db = await this.db;
const record = await db.get('snapshots', id);
return record?.snapshot;
}
async clearOldSnapshots(maxAge: number = 7 * 24 * 60 * 60 * 1000) {
const db = await this.db;
const tx = db.transaction('snapshots', 'readwrite');
const store = tx.objectStore('snapshots');
const all = await store.getAll();
const now = Date.now();
for (const record of all) {
if (now - record.timestamp > maxAge) {
await store.delete(record.id);
}
}
}
}
// Usage
const persistence = new StatePersistence();
// Save
const actor = createActor(machine);
actor.subscribe((snapshot) => {
persistence.saveSnapshot('app-state', snapshot);
});
// Load
const snapshot = await persistence.loadSnapshot('app-state');
const actor = createActor(machine, { snapshot });Best Practices
Persistence Checklist
✅ Version your state to handle schema changes ✅ Don't persist transient state (loading, errors) ✅ Handle serialization errors gracefully ✅ Set expiration for stale data ✅ Compress large states to save space ✅ Use IndexedDB for large datasets ✅ Test hydration in development ✅ Handle missing data on load ✅ Clear old data periodically ✅ Validate loaded state before using
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Persisting everything | Bloated storage, slow loads | Selective persistence |
| No versioning | Breaks on schema changes | Version + migration |
| Circular references | JSON.stringify fails | Custom serializer |
| Large localStorage | 5-10MB limit | Use IndexedDB |
| Hydration mismatch | React warnings | Match server/client |
| Stale data | Using outdated state | Add timestamps |
React Integration Patterns
Hook Selection Guide
useMachine: Component-Scoped State
Most straightforward pattern—creates and starts actor from machine logic:
import { useMachine } from '@xstate/react';
import { toggleMachine } from './machines/toggleMachine';
function Toggle() {
const [snapshot, send, actorRef] = useMachine(toggleMachine);
return (
<div>
<p>Current state: {snapshot.value}</p>
<p>Count: {snapshot.context.count}</p>
<button onClick={() => send({ type: 'TOGGLE' })}>
{snapshot.matches('inactive') ? 'Turn On' : 'Turn Off'}
</button>
</div>
);
}Caveat: Re-renders on every state change. Fine for simple components; problematic for complex machines with frequent transitions.
useActorRef + useSelector: Performance Optimization
Separate actor reference from state subscriptions for selective re-renders:
import { useActorRef, useSelector } from '@xstate/react';
import { complexMachine } from './machines/complexMachine';
// Define selectors OUTSIDE component to prevent recreation
const selectCount = (snapshot) => snapshot.context.count;
const selectIsLoading = (snapshot) => snapshot.matches('loading');
const selectUser = (snapshot) => snapshot.context.user;
const selectError = (snapshot) => snapshot.context.error?.message;
function Dashboard() {
const actorRef = useActorRef(complexMachine);
// Each selector causes re-render ONLY when its value changes
const count = useSelector(actorRef, selectCount);
const isLoading = useSelector(actorRef, selectIsLoading);
const user = useSelector(actorRef, selectUser);
const error = useSelector(actorRef, selectError);
return (
<div>
<header>
<span>Welcome, {user?.name}</span>
<span>Actions: {count}</span>
</header>
{isLoading && <LoadingSpinner />}
{error && <ErrorBanner message={error} />}
<button onClick={() => actorRef.send({ type: 'INCREMENT' })}>
Do Action
</button>
</div>
);
}Comparison selectors for complex values
import { shallowEqual } from '@xstate/react';
// For arrays/objects, use comparison function
const selectTodos = (snapshot) => snapshot.context.todos;
function TodoList() {
const actorRef = useActorRef(todoMachine);
const todos = useSelector(actorRef, selectTodos, shallowEqual);
return (
<ul>
{todos.map(todo => <TodoItem key={todo.id} todo={todo} />)}
</ul>
);
}createActorContext: Global State Without Prop Drilling
For application-wide state machines:
import { createActorContext } from '@xstate/react';
import { appMachine } from './machines/appMachine';
// Create context outside components
export const AppMachineContext = createActorContext(appMachine);
// Provider wraps app
function App() {
return (
<AppMachineContext.Provider>
<Header />
<MainContent />
<Footer />
</AppMachineContext.Provider>
);
}
// Any descendant accesses machine
function Header() {
const user = AppMachineContext.useSelector(s => s.context.user);
const actorRef = AppMachineContext.useActorRef();
return (
<header>
<span>{user?.name}</span>
<button onClick={() => actorRef.send({ type: 'LOGOUT' })}>
Logout
</button>
</header>
);
}
function MainContent() {
const isAuthenticated = AppMachineContext.useSelector(
s => s.matches('authenticated')
);
return isAuthenticated ? <Dashboard /> : <LoginForm />;
}Provider with initial state or input
function App() {
return (
<AppMachineContext.Provider
options={{
input: { userId: 'user-123' },
// Or restore from snapshot:
// snapshot: JSON.parse(localStorage.getItem('appState'))
}}
>
<MainContent />
</AppMachineContext.Provider>
);
}Side Effects: Actions vs Invoked Actors
Actions (Fire-and-Forget)
Use for instant operations with no result handling:
const machine = setup({
actions: {
// Logging
logTransition: ({ event }) => {
console.log('Transition:', event.type);
},
// Analytics
trackEvent: ({ context, event }) => {
analytics.track(event.type, { userId: context.user?.id });
},
// DOM manipulation (rare, prefer React)
focusInput: () => {
document.getElementById('search-input')?.focus();
},
// Context updates
updateCount: assign({
count: ({ context }) => context.count + 1
})
}
}).createMachine({
states: {
idle: {
entry: 'logTransition',
on: {
SEARCH: {
target: 'searching',
actions: ['trackEvent', 'updateCount']
}
}
}
}
});Invoked Actors (Managed Lifecycle)
Use for async operations with results, errors, and cleanup:
const searchMachine = setup({
actors: {
searchAPI: fromPromise(async ({ input, signal }) => {
const response = await fetch(
`/api/search?q=${encodeURIComponent(input.query)}`,
{ signal } // Automatic abort on state exit
);
if (!response.ok) throw new Error('Search failed');
return response.json();
})
}
}).createMachine({
initial: 'idle',
context: { query: '', results: [], error: null },
states: {
idle: {
on: {
SEARCH: {
target: 'searching',
actions: assign({ query: ({ event }) => event.query })
}
}
},
searching: {
invoke: {
src: 'searchAPI',
input: ({ context }) => ({ query: context.query }),
onDone: {
target: 'success',
actions: assign({
results: ({ event }) => event.output,
error: null
})
},
onError: {
target: 'failure',
actions: assign({
error: ({ event }) => event.error.message
})
}
},
on: {
CANCEL: 'idle' // Exiting state aborts the fetch
}
},
success: {
on: { SEARCH: 'searching', CLEAR: 'idle' }
},
failure: {
on: { RETRY: 'searching', CLEAR: 'idle' }
}
}
});Common UI Patterns
Multi-Step Form with Validation
const signUpMachine = setup({
types: {
context: {} as {
step: number;
name: string;
email: string;
password: string;
plan: 'free' | 'pro' | 'enterprise';
errors: Record<string, string>;
}
},
guards: {
hasValidName: ({ context }) => context.name.length >= 2,
hasValidEmail: ({ context }) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(context.email),
hasValidPassword: ({ context }) => context.password.length >= 8,
hasSelectedPlan: ({ context }) => !!context.plan
},
actors: {
submitForm: fromPromise(async ({ input }) => {
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
});
if (!response.ok) throw new Error('Signup failed');
return response.json();
})
}
}).createMachine({
id: 'signUp',
initial: 'personalInfo',
context: {
step: 1,
name: '',
email: '',
password: '',
plan: 'free',
errors: {}
},
states: {
personalInfo: {
on: {
UPDATE_NAME: { actions: assign({ name: ({ event }) => event.value }) },
UPDATE_EMAIL: { actions: assign({ email: ({ event }) => event.value }) },
NEXT: [
{
target: 'credentials',
guard: 'hasValidName',
actions: assign({ step: 2 })
},
{
actions: assign({
errors: { name: 'Name must be at least 2 characters' }
})
}
]
}
},
credentials: {
on: {
UPDATE_PASSWORD: { actions: assign({ password: ({ event }) => event.value }) },
BACK: { target: 'personalInfo', actions: assign({ step: 1 }) },
NEXT: [
{
target: 'planSelection',
guard: 'hasValidPassword',
actions: assign({ step: 3 })
},
{
actions: assign({
errors: { password: 'Password must be at least 8 characters' }
})
}
]
}
},
planSelection: {
on: {
SELECT_PLAN: { actions: assign({ plan: ({ event }) => event.plan }) },
BACK: { target: 'credentials', actions: assign({ step: 2 }) },
NEXT: { target: 'confirmation', actions: assign({ step: 4 }) }
}
},
confirmation: {
on: {
BACK: { target: 'planSelection', actions: assign({ step: 3 }) },
SUBMIT: 'submitting'
}
},
submitting: {
invoke: {
src: 'submitForm',
input: ({ context }) => ({
name: context.name,
email: context.email,
password: context.password,
plan: context.plan
}),
onDone: 'success',
onError: {
target: 'confirmation',
actions: assign({
errors: ({ event }) => ({ submit: event.error.message })
})
}
}
},
success: { type: 'final' }
}
});React component for form
function SignUpForm() {
const [snapshot, send] = useMachine(signUpMachine);
const { step, name, email, password, plan, errors } = snapshot.context;
return (
<div className="signup-form">
<ProgressIndicator currentStep={step} totalSteps={4} />
{snapshot.matches('personalInfo') && (
<div>
<input
value={name}
onChange={(e) => send({ type: 'UPDATE_NAME', value: e.target.value })}
placeholder="Name"
/>
{errors.name && <span className="error">{errors.name}</span>}
<button onClick={() => send({ type: 'NEXT' })}>Next</button>
</div>
)}
{snapshot.matches('credentials') && (
<div>
<input
type="password"
value={password}
onChange={(e) => send({ type: 'UPDATE_PASSWORD', value: e.target.value })}
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password}</span>}
<button onClick={() => send({ type: 'BACK' })}>Back</button>
<button onClick={() => send({ type: 'NEXT' })}>Next</button>
</div>
)}
{snapshot.matches('submitting') && <LoadingSpinner />}
{snapshot.matches('success') && (
<div className="success">
<h2>Welcome!</h2>
<p>Your account has been created.</p>
</div>
)}
</div>
);
}Modal with Animation States
const modalMachine = createMachine({
id: 'modal',
initial: 'closed',
states: {
closed: {
on: { OPEN: 'opening' }
},
opening: {
after: { 300: 'open' }
},
open: {
on: { CLOSE: 'closing' }
},
closing: {
after: { 300: 'closed' }
}
}
});
function Modal({ children }) {
const [snapshot, send] = useMachine(modalMachine);
// Don't render when fully closed
if (snapshot.matches('closed')) return null;
const animationClass = snapshot.matches('opening')
? 'modal-enter'
: snapshot.matches('closing')
? 'modal-exit'
: 'modal-visible';
return (
<div className={`modal-overlay ${animationClass}`}>
<div className="modal-content">
<button
className="modal-close"
onClick={() => send({ type: 'CLOSE' })}
>
×
</button>
{children}
</div>
</div>
);
}Debounced Search with Cancel
const searchMachine = setup({
actors: {
search: fromPromise(async ({ input, signal }) => {
const res = await fetch(`/api/search?q=${input.query}`, { signal });
return res.json();
})
}
}).createMachine({
initial: 'idle',
context: { query: '', results: [] },
states: {
idle: {
on: {
TYPE: {
target: 'debouncing',
actions: assign({ query: ({ event }) => event.value })
}
}
},
debouncing: {
after: {
300: 'searching' // Wait 300ms before searching
},
on: {
TYPE: {
target: 'debouncing', // Restart debounce
actions: assign({ query: ({ event }) => event.value })
}
}
},
searching: {
invoke: {
src: 'search',
input: ({ context }) => ({ query: context.query }),
onDone: {
target: 'idle',
actions: assign({ results: ({ event }) => event.output })
},
onError: 'idle'
},
on: {
TYPE: {
target: 'debouncing', // Cancel current, start new
actions: assign({ query: ({ event }) => event.value })
}
}
}
}
});Integrating with External State
React Query / TanStack Query
State machines handle UI state; React Query handles server state:
function UserProfile() {
// Server state via React Query
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
// UI state via state machine
const [snapshot, send] = useMachine(profileUIMachine);
return (
<div>
{isLoading && <Skeleton />}
{error && <ErrorMessage error={error} />}
{user && (
<>
<UserCard user={user} />
{snapshot.matches('editing') && (
<EditForm
user={user}
onSave={() => send({ type: 'SAVE' })}
onCancel={() => send({ type: 'CANCEL' })}
/>
)}
</>
)}
</div>
);
}Zustand for simple global state
import { create } from 'zustand';
import { createActorContext } from '@xstate/react';
// Zustand for simple preferences
const usePreferences = create((set) => ({
theme: 'dark',
setTheme: (theme) => set({ theme })
}));
// XState for complex workflows
const WorkflowContext = createActorContext(workflowMachine);
function App() {
const theme = usePreferences(s => s.theme);
return (
<div className={theme}>
<WorkflowContext.Provider>
<MainContent />
</WorkflowContext.Provider>
</div>
);
}Real-World State Machine Patterns
Complete Authentication Flow
Machine Definition
import { setup, assign, fromPromise } from 'xstate';
interface User {
id: string;
email: string;
name: string;
token: string;
refreshToken: string;
}
interface AuthContext {
user: User | null;
error: string | null;
sessionExpiry: number | null;
}
type AuthEvent =
| { type: 'LOGIN'; email: string; password: string }
| { type: 'LOGOUT' }
| { type: 'REFRESH_SESSION' }
| { type: 'SESSION_EXPIRED' }
| { type: 'RETRY' };
export const authMachine = setup({
types: {
context: {} as AuthContext,
events: {} as AuthEvent
},
actors: {
loginUser: fromPromise(async ({ input }: { input: { email: string; password: string } }) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
return response.json() as Promise<User>;
}),
refreshSession: fromPromise(async ({ input }: { input: { refreshToken: string } }) => {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: input.refreshToken })
});
if (!response.ok) throw new Error('Session refresh failed');
return response.json() as Promise<User>;
}),
logoutUser: fromPromise(async ({ input }: { input: { token: string } }) => {
await fetch('/api/auth/logout', {
method: 'POST',
headers: { Authorization: `Bearer ${input.token}` }
});
})
},
actions: {
setUser: assign({
user: ({ event }) => event.output,
error: null,
sessionExpiry: () => Date.now() + 3600000 // 1 hour
}),
clearUser: assign({
user: null,
error: null,
sessionExpiry: null
}),
setError: assign({
error: ({ event }) => event.error.message
}),
persistSession: ({ context }) => {
if (context.user) {
localStorage.setItem('auth_token', context.user.token);
localStorage.setItem('refresh_token', context.user.refreshToken);
}
},
clearSession: () => {
localStorage.removeItem('auth_token');
localStorage.removeItem('refresh_token');
}
},
guards: {
hasRefreshToken: ({ context }) => !!context.user?.refreshToken
}
}).createMachine({
id: 'auth',
initial: 'checkingSession',
context: {
user: null,
error: null,
sessionExpiry: null
},
states: {
checkingSession: {
always: [
{ target: 'authenticated', guard: ({ context }) => !!context.user },
{ target: 'unauthenticated' }
]
},
unauthenticated: {
on: {
LOGIN: 'authenticating'
}
},
authenticating: {
invoke: {
src: 'loginUser',
input: ({ event }) => ({
email: event.email,
password: event.password
}),
onDone: {
target: 'authenticated',
actions: ['setUser', 'persistSession']
},
onError: {
target: 'authenticationFailed',
actions: 'setError'
}
}
},
authenticationFailed: {
on: {
RETRY: 'unauthenticated',
LOGIN: 'authenticating'
}
},
authenticated: {
// Auto-refresh before expiry
after: {
3300000: { // 55 minutes
target: 'refreshing',
guard: 'hasRefreshToken'
}
},
on: {
LOGOUT: 'loggingOut',
SESSION_EXPIRED: 'sessionExpired',
REFRESH_SESSION: 'refreshing'
}
},
refreshing: {
invoke: {
src: 'refreshSession',
input: ({ context }) => ({
refreshToken: context.user!.refreshToken
}),
onDone: {
target: 'authenticated',
actions: ['setUser', 'persistSession']
},
onError: 'sessionExpired'
}
},
sessionExpired: {
entry: 'clearSession',
on: {
LOGIN: 'authenticating'
}
},
loggingOut: {
invoke: {
src: 'logoutUser',
input: ({ context }) => ({
token: context.user!.token
}),
onDone: {
target: 'unauthenticated',
actions: ['clearUser', 'clearSession']
},
onError: {
target: 'unauthenticated',
actions: ['clearUser', 'clearSession']
}
}
}
}
});React Integration
import { useMachine } from '@xstate/react';
import { authMachine } from './authMachine';
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [snapshot, send] = useMachine(authMachine);
return (
<AuthContext.Provider value={{ snapshot, send }}>
{children}
</AuthContext.Provider>
);
}
export function LoginForm() {
const { snapshot, send } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
send({ type: 'LOGIN', email, password });
};
if (snapshot.matches('authenticated')) {
return <Navigate to="/dashboard" />;
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{snapshot.matches('authenticationFailed') && (
<div className="error">{snapshot.context.error}</div>
)}
<button
type="submit"
disabled={snapshot.matches('authenticating')}
>
{snapshot.matches('authenticating') ? 'Logging in...' : 'Login'}
</button>
</form>
);
}File Upload with Progress
Machine Definition
import { setup, assign, fromPromise, fromCallback } from 'xstate';
interface UploadContext {
file: File | null;
progress: number;
uploadedUrl: string | null;
error: string | null;
abortController: AbortController | null;
}
type UploadEvent =
| { type: 'SELECT_FILE'; file: File }
| { type: 'START_UPLOAD' }
| { type: 'CANCEL' }
| { type: 'RETRY' }
| { type: 'RESET' };
export const fileUploadMachine = setup({
types: {
context: {} as UploadContext,
events: {} as UploadEvent
},
actors: {
uploadFile: fromCallback(({ sendBack, receive, input }) => {
const { file } = input as { file: File };
const abortController = new AbortController();
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100);
sendBack({ type: 'PROGRESS_UPDATE', progress });
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
const response = JSON.parse(xhr.responseText);
sendBack({ type: 'UPLOAD_SUCCESS', url: response.url });
} else {
sendBack({ type: 'UPLOAD_ERROR', error: 'Upload failed' });
}
});
xhr.addEventListener('error', () => {
sendBack({ type: 'UPLOAD_ERROR', error: 'Network error' });
});
xhr.addEventListener('abort', () => {
sendBack({ type: 'UPLOAD_CANCELLED' });
});
// Handle cancel from parent
receive((event) => {
if (event.type === 'CANCEL') {
xhr.abort();
}
});
const formData = new FormData();
formData.append('file', file);
xhr.open('POST', '/api/upload');
xhr.send(formData);
return () => xhr.abort();
})
},
actions: {
setFile: assign({
file: ({ event }) => event.file,
error: null
}),
updateProgress: assign({
progress: ({ event }) => event.progress
}),
setUploadedUrl: assign({
uploadedUrl: ({ event }) => event.url,
progress: 100
}),
setError: assign({
error: ({ event }) => event.error
}),
resetUpload: assign({
file: null,
progress: 0,
uploadedUrl: null,
error: null,
abortController: null
})
},
guards: {
hasFile: ({ context }) => context.file !== null,
isValidFileSize: ({ context }) => {
const maxSize = 10 * 1024 * 1024; // 10MB
return context.file ? context.file.size <= maxSize : false;
},
isValidFileType: ({ context }) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
return context.file ? allowedTypes.includes(context.file.type) : false;
}
}
}).createMachine({
id: 'fileUpload',
initial: 'idle',
context: {
file: null,
progress: 0,
uploadedUrl: null,
error: null,
abortController: null
},
states: {
idle: {
on: {
SELECT_FILE: {
target: 'validating',
actions: 'setFile'
}
}
},
validating: {
always: [
{
target: 'invalid',
guard: ({ context }) => !context.file,
actions: assign({ error: 'No file selected' })
},
{
target: 'invalid',
guard: ({ context }) => {
const maxSize = 10 * 1024 * 1024;
return context.file ? context.file.size > maxSize : true;
},
actions: assign({ error: 'File too large (max 10MB)' })
},
{
target: 'invalid',
guard: ({ context }) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
return context.file ? !allowed.includes(context.file.type) : true;
},
actions: assign({ error: 'Invalid file type' })
},
{ target: 'ready' }
]
},
invalid: {
on: {
SELECT_FILE: {
target: 'validating',
actions: 'setFile'
},
RESET: {
target: 'idle',
actions: 'resetUpload'
}
}
},
ready: {
on: {
START_UPLOAD: 'uploading',
SELECT_FILE: {
target: 'validating',
actions: 'setFile'
},
RESET: {
target: 'idle',
actions: 'resetUpload'
}
}
},
uploading: {
invoke: {
src: 'uploadFile',
input: ({ context }) => ({ file: context.file! })
},
on: {
PROGRESS_UPDATE: {
actions: 'updateProgress'
},
UPLOAD_SUCCESS: {
target: 'success',
actions: 'setUploadedUrl'
},
UPLOAD_ERROR: {
target: 'failed',
actions: 'setError'
},
UPLOAD_CANCELLED: 'cancelled',
CANCEL: 'cancelling'
}
},
cancelling: {
after: {
100: 'cancelled'
}
},
cancelled: {
on: {
RETRY: 'ready',
RESET: {
target: 'idle',
actions: 'resetUpload'
}
}
},
failed: {
on: {
RETRY: 'uploading',
RESET: {
target: 'idle',
actions: 'resetUpload'
}
}
},
success: {
on: {
RESET: {
target: 'idle',
actions: 'resetUpload'
}
}
}
}
});React Component
import { useMachine } from '@xstate/react';
import { fileUploadMachine } from './fileUploadMachine';
export function FileUploader() {
const [snapshot, send] = useMachine(fileUploadMachine);
const { file, progress, uploadedUrl, error } = snapshot.context;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
send({ type: 'SELECT_FILE', file: selectedFile });
}
};
return (
<div className="file-uploader">
{snapshot.matches('idle') && (
<div>
<input
type="file"
onChange={handleFileSelect}
accept="image/*,.pdf"
/>
</div>
)}
{snapshot.matches('invalid') && (
<div className="error">
{error}
<button onClick={() => send({ type: 'RESET' })}>Try Again</button>
</div>
)}
{snapshot.matches('ready') && file && (
<div>
<p>Selected: {file.name} ({(file.size / 1024).toFixed(2)} KB)</p>
<button onClick={() => send({ type: 'START_UPLOAD' })}>Upload</button>
<button onClick={() => send({ type: 'RESET' })}>Cancel</button>
</div>
)}
{snapshot.matches('uploading') && (
<div>
<p>Uploading {file?.name}...</p>
<progress value={progress} max={100} />
<span>{progress}%</span>
<button onClick={() => send({ type: 'CANCEL' })}>Cancel Upload</button>
</div>
)}
{snapshot.matches('failed') && (
<div className="error">
<p>Upload failed: {error}</p>
<button onClick={() => send({ type: 'RETRY' })}>Retry</button>
<button onClick={() => send({ type: 'RESET' })}>Start Over</button>
</div>
)}
{snapshot.matches('success') && uploadedUrl && (
<div className="success">
<p>Upload successful!</p>
<a href={uploadedUrl} target="_blank" rel="noopener noreferrer">View File</a>
<button onClick={() => send({ type: 'RESET' })}>Upload Another</button>
</div>
)}
</div>
);
}Undo/Redo Pattern
Machine Definition
import { setup, assign } from 'xstate';
interface HistoryContext<T> {
past: T[];
present: T;
future: T[];
canUndo: boolean;
canRedo: boolean;
}
type HistoryEvent<T> =
| { type: 'UPDATE'; value: T }
| { type: 'UNDO' }
| { type: 'REDO' }
| { type: 'CLEAR_HISTORY' };
export function createHistoryMachine<T>(initialValue: T) {
return setup({
types: {
context: {} as HistoryContext<T>,
events: {} as HistoryEvent<T>
},
actions: {
updateValue: assign({
past: ({ context, event }) => [...context.past, context.present],
present: ({ event }) => event.value,
future: [], // Clear future on new action
canUndo: true,
canRedo: false
}),
undo: assign({
past: ({ context }) => context.past.slice(0, -1),
present: ({ context }) => context.past[context.past.length - 1],
future: ({ context }) => [context.present, ...context.future],
canUndo: ({ context }) => context.past.length > 1,
canRedo: true
}),
redo: assign({
past: ({ context }) => [...context.past, context.present],
present: ({ context }) => context.future[0],
future: ({ context }) => context.future.slice(1),
canUndo: true,
canRedo: ({ context }) => context.future.length > 1
}),
clearHistory: assign({
past: [],
future: [],
canUndo: false,
canRedo: false
})
},
guards: {
canUndo: ({ context }) => context.past.length > 0,
canRedo: ({ context }) => context.future.length > 0
}
}).createMachine({
id: 'history',
initial: 'idle',
context: {
past: [],
present: initialValue,
future: [],
canUndo: false,
canRedo: false
},
states: {
idle: {
on: {
UPDATE: {
actions: 'updateValue'
},
UNDO: {
guard: 'canUndo',
actions: 'undo'
},
REDO: {
guard: 'canRedo',
actions: 'redo'
},
CLEAR_HISTORY: {
actions: 'clearHistory'
}
}
}
}
});
}React Integration (Drawing App Example)
import { useMachine } from '@xstate/react';
import { createHistoryMachine } from './historyMachine';
interface DrawingState {
paths: Path[];
color: string;
strokeWidth: number;
}
export function DrawingApp() {
const [snapshot, send] = useMachine(
createHistoryMachine<DrawingState>({
paths: [],
color: '#000000',
strokeWidth: 2
})
);
const { present, canUndo, canRedo } = snapshot.context;
const addPath = (path: Path) => {
send({
type: 'UPDATE',
value: {
...present,
paths: [...present.paths, path]
}
});
};
const changeColor = (color: string) => {
send({
type: 'UPDATE',
value: { ...present, color }
});
};
return (
<div>
<div className="toolbar">
<button
onClick={() => send({ type: 'UNDO' })}
disabled={!canUndo}
>
Undo
</button>
<button
onClick={() => send({ type: 'REDO' })}
disabled={!canRedo}
>
Redo
</button>
<input
type="color"
value={present.color}
onChange={(e) => changeColor(e.target.value)}
/>
</div>
<Canvas
paths={present.paths}
color={present.color}
strokeWidth={present.strokeWidth}
onPathComplete={addPath}
/>
</div>
);
}Multi-Step Wizard/Stepper Pattern
Machine Definition
import { setup, assign } from 'xstate';
interface WizardStep {
id: string;
title: string;
isValid: (data: any) => boolean;
}
interface WizardContext {
currentStep: number;
totalSteps: number;
data: Record<string, any>;
errors: Record<string, string>;
steps: WizardStep[];
}
type WizardEvent =
| { type: 'NEXT' }
| { type: 'PREVIOUS' }
| { type: 'GO_TO_STEP'; step: number }
| { type: 'UPDATE_DATA'; field: string; value: any }
| { type: 'SUBMIT' }
| { type: 'RESET' };
export const wizardMachine = setup({
types: {
context: {} as WizardContext,
events: {} as WizardEvent
},
actions: {
updateData: assign({
data: ({ context, event }) => ({
...context.data,
[event.field]: event.value
}),
errors: ({ context, event }) => {
const { [event.field]: _, ...rest } = context.errors;
return rest;
}
}),
nextStep: assign({
currentStep: ({ context }) => Math.min(context.currentStep + 1, context.totalSteps - 1)
}),
previousStep: assign({
currentStep: ({ context }) => Math.max(context.currentStep - 1, 0)
}),
goToStep: assign({
currentStep: ({ event }) => event.step
}),
setValidationError: assign({
errors: ({ context, event }) => ({
...context.errors,
[event.field]: event.message
})
}),
resetWizard: assign({
currentStep: 0,
data: {},
errors: {}
})
},
guards: {
isCurrentStepValid: ({ context }) => {
const currentStepDef = context.steps[context.currentStep];
return currentStepDef.isValid(context.data);
},
isNotFirstStep: ({ context }) => context.currentStep > 0,
isNotLastStep: ({ context }) => context.currentStep < context.totalSteps - 1,
canGoToStep: ({ context, event }) => {
// Can only go to visited steps or next unvisited step
return event.step <= context.currentStep + 1 && event.step >= 0;
}
}
}).createMachine({
id: 'wizard',
initial: 'editing',
context: ({ input }: { input: { steps: WizardStep[] } }) => ({
currentStep: 0,
totalSteps: input.steps.length,
data: {},
errors: {},
steps: input.steps
}),
states: {
editing: {
on: {
UPDATE_DATA: {
actions: 'updateData'
},
NEXT: [
{
guard: 'isCurrentStepValid',
actions: 'nextStep'
},
{
actions: 'setValidationError'
}
],
PREVIOUS: {
guard: 'isNotFirstStep',
actions: 'previousStep'
},
GO_TO_STEP: {
guard: 'canGoToStep',
actions: 'goToStep'
},
SUBMIT: {
guard: 'isCurrentStepValid',
target: 'submitting'
}
}
},
submitting: {
invoke: {
src: 'submitWizard',
input: ({ context }) => context.data,
onDone: 'success',
onError: {
target: 'editing',
actions: assign({
errors: ({ event }) => ({ submit: event.error.message })
})
}
}
},
success: {
on: {
RESET: {
target: 'editing',
actions: 'resetWizard'
}
}
}
}
});React Component
import { useMachine } from '@xstate/react';
import { wizardMachine } from './wizardMachine';
const steps = [
{
id: 'personal',
title: 'Personal Information',
isValid: (data) => data.name && data.email
},
{
id: 'address',
title: 'Address',
isValid: (data) => data.street && data.city && data.zip
},
{
id: 'payment',
title: 'Payment',
isValid: (data) => data.cardNumber && data.cvv
},
{
id: 'review',
title: 'Review',
isValid: () => true
}
];
export function Wizard() {
const [snapshot, send] = useMachine(wizardMachine, {
input: { steps }
});
const { currentStep, totalSteps, data, errors } = snapshot.context;
const currentStepDef = steps[currentStep];
return (
<div className="wizard">
{/* Progress Indicator */}
<div className="wizard-progress">
{steps.map((step, index) => (
<div
key={step.id}
className={`step ${index === currentStep ? 'active' : ''} ${
index < currentStep ? 'completed' : ''
}`}
onClick={() => send({ type: 'GO_TO_STEP', step: index })}
>
<div className="step-number">{index + 1}</div>
<div className="step-title">{step.title}</div>
</div>
))}
</div>
{/* Step Content */}
<div className="wizard-content">
{currentStep === 0 && (
<PersonalInfoStep
data={data}
errors={errors}
onChange={(field, value) =>
send({ type: 'UPDATE_DATA', field, value })
}
/>
)}
{currentStep === 1 && (
<AddressStep
data={data}
errors={errors}
onChange={(field, value) =>
send({ type: 'UPDATE_DATA', field, value })
}
/>
)}
{currentStep === 2 && (
<PaymentStep
data={data}
errors={errors}
onChange={(field, value) =>
send({ type: 'UPDATE_DATA', field, value })
}
/>
)}
{currentStep === 3 && <ReviewStep data={data} />}
</div>
{/* Navigation */}
<div className="wizard-actions">
{currentStep > 0 && (
<button onClick={() => send({ type: 'PREVIOUS' })}>
Previous
</button>
)}
{currentStep < totalSteps - 1 && (
<button onClick={() => send({ type: 'NEXT' })}>
Next
</button>
)}
{currentStep === totalSteps - 1 && (
<button
onClick={() => send({ type: 'SUBMIT' })}
disabled={snapshot.matches('submitting')}
>
{snapshot.matches('submitting') ? 'Submitting...' : 'Submit'}
</button>
)}
</div>
</div>
);
}Related skills
FAQ
When should I use a state machine instead of useState?
For complex async flows or when boolean flags like isLoading/isError proliferate; simple toggles should stay in useState.
What XState version does this skill target?
XState v5 with the setup() pattern and the actor model.