MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

State Machines in JavaScript with XState

Model complex UI logic with finite state machines using XState: states, transitions, guards.

XStateState MachinesJavaScriptFrontend

By MinhVo

Introduction

Every frontend developer has faced the nightmare of managing complex UI state: a modal that can be opening, open, closing, or closed; a form that handles validation, submission, success, and error states; or an async data fetch with loading, loaded, error, and empty states. The typical approach — boolean flags and useEffect chains — leads to impossible-to-debug state combinations where isLoading and isError are both true simultaneously.

State machines eliminate this entire class of bugs by design. A finite state machine can only be in one state at a time, and transitions between states are explicit and deterministic. You cannot accidentally reach an impossible state because the machine simply does not allow it.

XState is the most mature state machine library for JavaScript, built on the W3C SCXML specification. It provides a visual editor, comprehensive TypeScript support, and an actor model for orchestrating complex workflows. In this guide, we will build real state machines for common UI patterns, explore guards and actions, and integrate XState with React for production applications.

State Machine Visualization

Understanding State Machines: Core Concepts

A finite state machine has four components:

  1. States: The possible conditions the system can be in (e.g., idle, loading, success, error)
  2. Events: Things that happen that might trigger a transition (e.g., FETCH, SUCCESS, ERROR)
  3. Transitions: Rules that determine which state follows which event from which current state
  4. Actions: Side effects that execute during transitions (e.g., API calls, logging, state updates)

The power is in the constraints. A transition table explicitly defines every valid path through your system. If an event arrives in a state where no transition is defined, the machine ignores it. This eliminates impossible states and undefined behavior by construction.

Consider a fetch operation. With boolean flags, you can have isLoading: true, isError: true, data: someData — a nonsensical state. With a state machine, the system is in exactly one of idle | loading | success | error, and each state has a clear set of valid next states.

XState extends basic state machines with hierarchical states (states within states), parallel states (independent state regions), guards (conditional transitions), actions (side effects), and invocations (spawning child actors for async work).

When to Use State Machines

State machines excel when:

  • Your UI has multiple distinct states with specific transitions
  • Boolean flags proliferate (isLoading, isOpen, hasError, isSubmitting)
  • You need to handle edge cases like race conditions and cancellation
  • Business rules define valid state transitions
  • You need to test state logic independently of the UI

They add overhead when:

  • State is simple (a single toggle or counter)
  • The team is unfamiliar with the paradigm and the project is small

Architecture and Design Patterns

XState Machine Definition

An XState machine is defined as a configuration object:

import { createMachine, assign } from 'xstate';
 
const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: {
    data: null as unknown,
    error: null as string | null,
    retries: 0,
  },
  states: {
    idle: {
      on: {
        FETCH: { target: 'loading' },
      },
    },
    loading: {
      invoke: {
        src: 'fetchData',
        onDone: { target: 'success', actions: 'setData' },
        onError: { target: 'error', actions: 'setError' },
      },
    },
    success: {
      on: {
        FETCH: { target: 'loading' },
        RESET: { target: 'idle', actions: 'clearData' },
      },
    },
    error: {
      on: {
        RETRY: { target: 'loading', actions: 'incrementRetries' },
        RESET: { target: 'idle', actions: 'clearData' },
      },
    },
  },
});

Context and Actions

Context holds extended state — data that is not part of the state itself but is needed for logic and display. Actions are fire-and-forget side effects that update context, call external APIs, or send events to other actors.

const fetchMachine = createMachine({
  context: {
    data: null,
    error: null,
    retries: 0,
  },
  // ...
}, {
  actions: {
    setData: assign({
      data: ({ event }) => event.output,
      retries: 0,
    }),
    setError: assign({
      error: ({ event }) => event.data.message,
    }),
    clearData: assign({
      data: null,
      error: null,
      retries: 0,
    }),
    incrementRetries: assign({
      retries: ({ context }) => context.retries + 1,
    }),
  },
});

assign() is the key action for updating context. It receives the current context and event, and returns partial context updates. Actions are pure by convention — they should not have side effects beyond context updates.

Guards (Guards)

Guards are conditions on transitions. They determine whether a transition should be taken based on the current context and event:

const formMachine = createMachine({
  context: {
    email: '',
    password: '',
    attempts: 0,
  },
  initial: 'editing',
  states: {
    editing: {
      on: {
        SUBMIT: {
          target: 'submitting',
          guard: 'isFormValid',
        },
      },
    },
    submitting: {
      // ...
    },
  },
}, {
  guards: {
    isFormValid: ({ context }) => {
      return context.email.includes('@') && context.password.length >= 8;
    },
    canRetry: ({ context }) => context.attempts < 3,
  },
});

If the guard returns false, the transition is not taken. The machine stays in the current state as if the event never happened.

JavaScript Development

Step-by-Step Implementation

Building a Multi-Step Form

Let us build a complete multi-step registration form with validation, async submission, and error handling:

import { createMachine, assign, setup } from 'xstate';
 
interface FormContext {
  step: number;
  data: {
    name: string;
    email: string;
    password: string;
  };
  errors: Record<string, string>;
  submissionError: string | null;
}
 
const registrationMachine = setup({
  types: {} as {
    context: FormContext;
    events:
      | { type: 'NEXT' }
      | { type: 'PREV' }
      | { type: 'UPDATE_FIELD'; field: string; value: string }
      | { type: 'SUBMIT' }
      | { type: 'RETRY' };
  },
  guards: {
    isStep1Valid: ({ context }) => context.data.name.length >= 2,
    isStep2Valid: ({ context }) =>
      context.data.email.includes('@') && context.data.email.includes('.'),
    isStep3Valid: ({ context }) => context.data.password.length >= 8,
  },
  actions: {
    updateField: assign({
      data: ({ context, event }) => ({
        ...context.data,
        [event.field]: event.value,
      }),
      errors: ({ context, event }) => {
        const { [event.field]: _, ...rest } = context.errors;
        return rest;
      },
    }),
    clearErrors: assign({ errors: {} }),
    setSubmissionError: assign({
      submissionError: ({ event }) => event.data.message,
    }),
    clearSubmissionError: assign({ submissionError: null }),
    resetForm: assign({
      step: 1,
      data: { name: '', email: '', password: '' },
      errors: {},
      submissionError: null,
    }),
  },
  actors: {
    submitRegistration: async ({ context }) => {
      const response = await fetch('/api/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(context.data),
      });
      if (!response.ok) throw new Error(await response.text());
      return response.json();
    },
  },
}).createMachine({
  id: 'registration',
  initial: 'step1',
  context: {
    step: 1,
    data: { name: '', email: '', password: '' },
    errors: {},
    submissionError: null,
  },
  states: {
    step1: {
      entry: assign({ step: 1 }),
      on: {
        UPDATE_FIELD: { actions: 'updateField' },
        NEXT: { target: 'step2', guard: 'isStep1Valid' },
      },
    },
    step2: {
      entry: assign({ step: 2 }),
      on: {
        UPDATE_FIELD: { actions: 'updateField' },
        PREV: { target: 'step1' },
        NEXT: { target: 'step3', guard: 'isStep2Valid' },
      },
    },
    step3: {
      entry: assign({ step: 3 }),
      on: {
        UPDATE_FIELD: { actions: 'updateField' },
        PREV: { target: 'step2' },
        SUBMIT: { target: 'submitting', guard: 'isStep3Valid' },
      },
    },
    submitting: {
      entry: 'clearSubmissionError',
      invoke: {
        src: 'submitRegistration',
        onDone: { target: 'success' },
        onError: { target: 'step3', actions: 'setSubmissionError' },
      },
    },
    success: {
      on: {
        RETRY: { target: 'step1', actions: 'resetForm' },
      },
    },
  },
});

Integrating with React

XState v5 provides useMachine for React integration:

import { useMachine } from '@xstate/react';
import { registrationMachine } from './registrationMachine';
 
function RegistrationForm() {
  const [state, send] = useMachine(registrationMachine);
 
  const handleFieldChange = (field: string, value: string) => {
    send({ type: 'UPDATE_FIELD', field, value });
  };
 
  if (state.matches('success')) {
    return (
      <div>
        <h2>Registration Complete!</h2>
        <button onClick={() => send({ type: 'RETRY' })}>Register Another</button>
      </div>
    );
  }
 
  return (
    <form onSubmit={(e) => { e.preventDefault(); send({ type: 'SUBMIT' }); }}>
      {state.matches('step1') && (
        <div>
          <input
            value={state.context.data.name}
            onChange={(e) => handleFieldChange('name', e.target.value)}
            placeholder="Name"
          />
          <button type="button" onClick={() => send({ type: 'NEXT' })}>
            Next
          </button>
        </div>
      )}
 
      {state.matches('step2') && (
        <div>
          <input
            value={state.context.data.email}
            onChange={(e) => handleFieldChange('email', e.target.value)}
            placeholder="Email"
          />
          <button type="button" onClick={() => send({ type: 'PREV' })}>Back</button>
          <button type="button" onClick={() => send({ type: 'NEXT' })}>Next</button>
        </div>
      )}
 
      {state.matches('step3') && (
        <div>
          <input
            type="password"
            value={state.context.data.password}
            onChange={(e) => handleFieldChange('password', e.target.value)}
            placeholder="Password"
          />
          {state.context.submissionError && (
            <p className="error">{state.context.submissionError}</p>
          )}
          <button type="button" onClick={() => send({ type: 'PREV' })}>Back</button>
          <button type="submit">Submit</button>
        </div>
      )}
    </form>
  );
}

Building a Data Fetching Machine

A reusable data fetching pattern with caching and retry logic:

import { createMachine, assign, setup } from 'xstate';
 
function createFetchMachine<T>(fetchFn: () => Promise<T>) {
  return setup({
    actors: {
      fetch: async () => fetchFn(),
    },
    actions: {
      setData: assign({
        data: ({ event }) => event.output as T,
        error: null,
        retries: 0,
      }),
      setError: assign({
        error: ({ event }) => event.data as Error,
      }),
      incrementRetries: assign({
        retries: ({ context }) => context.retries + 1,
      }),
    },
    guards: {
      canRetry: ({ context }) => context.retries < 3,
    },
  }).createMachine({
    id: 'fetch',
    initial: 'idle',
    context: {
      data: null as T | null,
      error: null as Error | null,
      retries: 0,
    },
    states: {
      idle: {
        on: { FETCH: 'loading' },
      },
      loading: {
        invoke: {
          src: 'fetch',
          onDone: 'success',
          onError: 'failure',
        },
      },
      success: {
        entry: 'setData',
        on: {
          FETCH: 'loading',
          INVALIDATE: 'loading',
        },
      },
      failure: {
        entry: 'setError',
        on: {
          RETRY: {
            target: 'loading',
            guard: 'canRetry',
            actions: 'incrementRetries',
          },
          FETCH: 'loading',
        },
      },
    },
  });
}

State Machine Design

Real-World Use Cases

Use Case 1: Media Player State Machine

A music player has complex state interactions — play, pause, skip, seek, shuffle, repeat — with many invalid transitions:

const playerMachine = setup({
  types: {} as {
    context: {
      track: string | null;
      position: number;
      duration: number;
      volume: number;
      isShuffle: boolean;
      repeatMode: 'none' | 'one' | 'all';
    };
    events:
      | { type: 'PLAY'; track?: string }
      | { type: 'PAUSE' }
      | { type: 'STOP' }
      | { type: 'SEEK'; position: number }
      | { type: 'VOLUME'; level: number }
      | { type: 'TOGGLE_SHUFFLE' }
      | { type: 'TOGGLE_REPEAT' };
  },
}).createMachine({
  id: 'player',
  initial: 'stopped',
  context: {
    track: null,
    position: 0,
    duration: 0,
    volume: 0.8,
    isShuffle: false,
    repeatMode: 'none',
  },
  states: {
    stopped: {
      on: {
        PLAY: { target: 'playing' },
      },
    },
    playing: {
      on: {
        PAUSE: { target: 'paused' },
        STOP: { target: 'stopped' },
        SEEK: { actions: assign({ position: ({ event }) => event.position }) },
      },
    },
    paused: {
      on: {
        PLAY: { target: 'playing' },
        STOP: { target: 'stopped' },
      },
    },
  },
  on: {
    VOLUME: { actions: assign({ volume: ({ event }) => event.level }) },
    TOGGLE_SHUFFLE: { actions: assign({ isShuffle: ({ context }) => !context.isShuffle }) },
    TOGGLE_REPEAT: {
      actions: assign({
        repeatMode: ({ context }) => {
          const modes = ['none', 'one', 'all'] as const;
          const idx = modes.indexOf(context.repeatMode);
          return modes[(idx + 1) % modes.length];
        },
      }),
    },
  },
});

Use Case 2: WebSocket Connection Manager

Managing WebSocket connections involves reconnection logic, heartbeat monitoring, and graceful degradation:

const wsMachine = setup({
  actors: {
    connect: ({ context }) => {
      return new Promise((resolve, reject) => {
        const ws = new WebSocket(context.url);
        ws.onopen = () => resolve(ws);
        ws.onerror = (e) => reject(e);
      });
    },
  },
}).createMachine({
  id: 'websocket',
  initial: 'disconnected',
  context: {
    url: 'wss://api.example.com/ws',
    retries: 0,
    maxRetries: 5,
    socket: null as WebSocket | null,
  },
  states: {
    disconnected: {
      on: { CONNECT: 'connecting' },
    },
    connecting: {
      invoke: {
        src: 'connect',
        onDone: { target: 'connected', actions: assign({ socket: ({ event }) => event.output, retries: 0 }) },
        onError: { target: 'reconnecting' },
      },
    },
    connected: {
      on: {
        DISCONNECT: { target: 'disconnected' },
        CONNECTION_LOST: 'reconnecting',
      },
    },
    reconnecting: {
      after: {
        1000: [
          { target: 'connecting', guard: ({ context }) => context.retries < 5, actions: assign({ retries: ({ context }) => context.retries + 1 }) },
          { target: 'failed' },
        ],
      },
    },
    failed: {
      on: { RETRY: { target: 'connecting', actions: assign({ retries: 0 }) } },
    },
  },
});

Use Case 3: Authentication Flow

A complete auth flow with login, token refresh, and session management:

const authMachine = setup({
  actors: {
    login: async ({ context }) => {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ email: context.email, password: context.password }),
      });
      if (!res.ok) throw new Error('Invalid credentials');
      return res.json();
    },
    refreshToken: async ({ context }) => {
      const res = await fetch('/api/refresh', {
        method: 'POST',
        headers: { Authorization: `Bearer ${context.refreshToken}` },
      });
      if (!res.ok) throw new Error('Token refresh failed');
      return res.json();
    },
  },
}).createMachine({
  id: 'auth',
  initial: 'anonymous',
  context: {
    email: '',
    password: '',
    token: null as string | null,
    refreshToken: null as string | null,
    user: null as object | null,
  },
  states: {
    anonymous: {
      on: {
        LOGIN: { target: 'authenticating' },
        SET_CREDENTIALS: { actions: assign({ email: ({ event }) => event.email, password: ({ event }) => event.password }) },
      },
    },
    authenticating: {
      invoke: {
        src: 'login',
        onDone: {
          target: 'authenticated',
          actions: assign({
            token: ({ event }) => event.output.token,
            refreshToken: ({ event }) => event.output.refreshToken,
            user: ({ event }) => event.output.user,
            password: '',
          }),
        },
        onError: { target: 'anonymous' },
      },
    },
    authenticated: {
      after: {
        300000: { target: 'refreshing' }, // Refresh after 5 minutes
      },
      on: { LOGOUT: { target: 'anonymous', actions: assign({ token: null, refreshToken: null, user: null }) } },
    },
    refreshing: {
      invoke: {
        src: 'refreshToken',
        onDone: { target: 'authenticated', actions: assign({ token: ({ event }) => event.output.token }) },
        onError: { target: 'anonymous', actions: assign({ token: null, refreshToken: null, user: null }) },
      },
    },
  },
});

Best Practices for Production

  1. Start with a state diagram: Before writing code, draw the state diagram. Use the XState visual editor (stately.ai/viz) to design and validate your machine visually.

  2. Keep context minimal: Context should hold data, not behavior. Derived values should be computed in selectors, not stored in context.

  3. Use guards for conditional logic: Guards are testable and visible in the state diagram. Conditional logic in actions is harder to reason about.

  4. Model every event explicitly: Define what every event does in every state. Use internal: false for transitions that exit and re-enter states (triggering entry/exit actions).

  5. Separate machine logic from UI: Define machines in separate files. The React component only calls send() and reads state. This makes machines testable without rendering.

  6. Use the actor model for complex workflows: When you need multiple machines communicating, use XState's actor model. A parent machine spawns child machines and communicates via events.

  7. Test state machines with path coverage: Test every valid path through the machine, not just happy paths. XState provides getShortestPaths() and getSimplePaths() for generating test cases.

  8. Version your state machines: When deploying state machines to production, version them. If context shape changes, provide migration logic for persisted state.

Common Pitfalls and Solutions

PitfallImpactSolution
Too many statesMachine becomes unmaintainableUse hierarchical states to group related states
Storing derived data in contextRedundant, can get out of syncCompute derived values in selectors
Actions with side effectsHard to test, unpredictableKeep actions pure; use actors for side effects
Not handling all events in each stateEvents silently ignoredUse explicit on for every relevant event
Over-using guardsComplex conditions hard to debugKeep guards simple; compose multiple guards
Persisting full machine stateSerialization issues with functionsPersist only context and current state value

Performance Optimization

XState is lightweight, but complex machines with many states and guards can benefit from optimization:

import { createMachine } from 'xstate';
 
// Memoized selectors for derived state
const selectors = {
  canSubmit: (state) =>
    state.matches('step3') && state.context.data.password.length >= 8,
  currentStepLabel: (state) => {
    const labels = { step1: 'Personal Info', step2: 'Contact', step3: 'Security' };
    return labels[state.value] || 'Unknown';
  },
  isNavigating: (state) =>
    state.matches('step1') || state.matches('step2') || state.matches('step3'),
};

For machines invoked frequently (inside lists), use useSelector to subscribe to specific slices:

import { useSelector } from '@xstate/react';
 
function TodoItem({ actor }) {
  const isCompleted = useSelector(actor, (state) => state.matches('completed'));
  return <li className={isCompleted ? 'done' : ''}>{/* ... */}</li>;
}

Testing Strategies

import { describe, test, expect } from 'vitest';
import { createActor } from 'xstate';
import { registrationMachine } from './registrationMachine';
 
describe('registrationMachine', () => {
  test('starts at step 1', () => {
    const actor = createActor(registrationMachine);
    actor.start();
    expect(actor.getSnapshot().value).toBe('step1');
  });
 
  test('cannot advance to step 2 with empty name', () => {
    const actor = createActor(registrationMachine);
    actor.start();
    actor.send({ type: 'NEXT' });
    expect(actor.getSnapshot().value).toBe('step1'); // Guard blocks transition
  });
 
  test('advances through all steps and submits', async () => {
    const actor = createActor(registrationMachine);
    actor.start();
 
    // Step 1
    actor.send({ type: 'UPDATE_FIELD', field: 'name', value: 'John Doe' });
    actor.send({ type: 'NEXT' });
    expect(actor.getSnapshot().value).toBe('step2');
 
    // Step 2
    actor.send({ type: 'UPDATE_FIELD', field: 'email', value: 'john@example.com' });
    actor.send({ type: 'NEXT' });
    expect(actor.getSnapshot().value).toBe('step3');
 
    // Step 3
    actor.send({ type: 'UPDATE_FIELD', field: 'password', value: 'securepassword123' });
    actor.send({ type: 'SUBMIT' });
    expect(actor.getSnapshot().value).toBe('submitting');
  });
 
  test('back navigation preserves context', () => {
    const actor = createActor(registrationMachine);
    actor.start();
 
    actor.send({ type: 'UPDATE_FIELD', field: 'name', value: 'Jane' });
    actor.send({ type: 'NEXT' });
    actor.send({ type: 'PREV' });
 
    expect(actor.getSnapshot().value).toBe('step1');
    expect(actor.getSnapshot().context.data.name).toBe('Jane');
  });
});

Future Outlook

XState v5 brings significant improvements: smaller bundle size, better TypeScript inference, and the setup API for cleaner machine composition. The Stately ecosystem is expanding with Stately Studio (visual editor), Stately Sky (hosted actors), and Stately Inspector (devtools).

The actor model is gaining traction beyond UI state. XState machines are being used for backend workflow orchestration, API gateway routing, and IoT device management. The W3C SCXML specification ensures that state machine knowledge transfers across languages and platforms.

The convergence of visual state modeling with AI-assisted development is particularly promising. Stately's AI features can generate state machines from natural language descriptions, lowering the barrier to adoption.

Conclusion

State machines transform chaotic boolean-flag state management into deterministic, testable, and visual systems. The key takeaways are:

  1. State machines enforce valid state transitions by design, eliminating impossible states
  2. XState provides a complete implementation of the SCXML specification for JavaScript
  3. Guards enable conditional transitions that are visible and testable
  4. Actions and actors separate side effects from state logic
  5. React integration via useMachine connects machines to UI components
  6. Visual state modeling (Stately) dramatically improves design and communication
  7. Test every path through the machine, not just happy paths

Start by modeling your most complex UI component as a state machine. The clarity you gain from explicit state definitions and valid transitions will change how you think about application state permanently.