Skip to main content
Technology & EngineeringState Management470 lines

XState State Machines

XState for state machines and statecharts in React — actors, guards, actions, services, @xstate/react integration, and the visual editor

Quick Summary24 lines
XState models application logic as finite state machines and statecharts. Instead of tracking booleans and flags that can combine into impossible states, you define explicit states and the transitions between them. Every possible state is declared upfront; every transition is intentional. This eliminates entire categories of bugs — "loading and error at the same time," "submitted but still editable," or "idle but showing a spinner."

## Key Points

- **Explicit states** — every possible state is named and accounted for; no implicit flag combinations
- **Event-driven transitions** — state changes only happen through defined events
- **Guards and actions** — conditional transitions and side effects are colocated with the machine definition
- **Actors model** — machines can spawn child actors for parallel and hierarchical logic
- **Visual editor** — Stately.ai provides a drag-and-drop editor that generates XState code
- **Framework-agnostic** — works with React, Vue, Svelte, or vanilla JS via adapters
- Renders statecharts as interactive diagrams
- Generates XState v5 code that you paste into your project
- Supports drag-and-drop state creation, transition drawing, and guard/action configuration
- Syncs with GitHub repos for live visualization of existing machines
- Simulates machine execution step by step for debugging
1. **Model states explicitly** — if your UI has "loading," "error," and "success" modes, make them states in the machine, not boolean flags.

## Quick Example

```bash
npm install xstate @xstate/react
```
skilldb get state-management-skills/XState State MachinesFull skill: 470 lines
Paste into your CLAUDE.md or agent config

XState State Machines & Statecharts

Core Philosophy

XState models application logic as finite state machines and statecharts. Instead of tracking booleans and flags that can combine into impossible states, you define explicit states and the transitions between them. Every possible state is declared upfront; every transition is intentional. This eliminates entire categories of bugs — "loading and error at the same time," "submitted but still editable," or "idle but showing a spinner."

  • Explicit states — every possible state is named and accounted for; no implicit flag combinations
  • Event-driven transitions — state changes only happen through defined events
  • Guards and actions — conditional transitions and side effects are colocated with the machine definition
  • Actors model — machines can spawn child actors for parallel and hierarchical logic
  • Visual editor — Stately.ai provides a drag-and-drop editor that generates XState code
  • Framework-agnostic — works with React, Vue, Svelte, or vanilla JS via adapters

Setup

npm install xstate @xstate/react
// machines/toggleMachine.ts
import { createMachine } from 'xstate';

export const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: {
      on: { TOGGLE: 'active' },
    },
    active: {
      on: { TOGGLE: 'inactive' },
    },
  },
});
// components/Toggle.tsx
import { useMachine } from '@xstate/react';
import { toggleMachine } from '../machines/toggleMachine';

export function Toggle() {
  const [state, send] = useMachine(toggleMachine);

  return (
    <button onClick={() => send({ type: 'TOGGLE' })}>
      {state.matches('active') ? 'ON' : 'OFF'}
    </button>
  );
}

Key Techniques

Context (Extended State)

import { createMachine, assign } from 'xstate';

interface CounterContext {
  count: number;
  max: number;
}

type CounterEvent =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'RESET' };

export const counterMachine = createMachine({
  id: 'counter',
  types: {} as {
    context: CounterContext;
    events: CounterEvent;
  },
  initial: 'active',
  context: {
    count: 0,
    max: 10,
  },
  states: {
    active: {
      on: {
        INCREMENT: {
          guard: ({ context }) => context.count < context.max,
          actions: assign({ count: ({ context }) => context.count + 1 }),
        },
        DECREMENT: {
          guard: ({ context }) => context.count > 0,
          actions: assign({ count: ({ context }) => context.count - 1 }),
        },
        RESET: {
          actions: assign({ count: 0 }),
        },
      },
    },
  },
});

Complex Machine — Authentication Flow

import { createMachine, assign } from 'xstate';

interface AuthContext {
  user: { id: string; name: string; email: string } | null;
  error: string | null;
  attempts: number;
}

type AuthEvent =
  | { type: 'LOGIN'; email: string; password: string }
  | { type: 'LOGOUT' }
  | { type: 'RETRY' };

export const authMachine = createMachine({
  id: 'auth',
  types: {} as {
    context: AuthContext;
    events: AuthEvent;
  },
  initial: 'idle',
  context: {
    user: null,
    error: null,
    attempts: 0,
  },
  states: {
    idle: {
      on: {
        LOGIN: 'authenticating',
      },
    },
    authenticating: {
      entry: assign({ error: null }),
      invoke: {
        id: 'loginService',
        src: 'loginUser',
        input: ({ event }) => {
          if (event.type === 'LOGIN') {
            return { email: event.email, password: event.password };
          }
          return {};
        },
        onDone: {
          target: 'authenticated',
          actions: assign({
            user: ({ event }) => event.output,
            attempts: 0,
          }),
        },
        onError: {
          target: 'error',
          actions: assign({
            error: ({ event }) => (event.error as Error).message,
            attempts: ({ context }) => context.attempts + 1,
          }),
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: {
          target: 'idle',
          actions: assign({ user: null }),
        },
      },
    },
    error: {
      on: {
        RETRY: {
          target: 'idle',
          guard: ({ context }) => context.attempts < 3,
        },
        LOGIN: 'authenticating',
      },
    },
  },
});

Guards (Conditional Transitions)

import { createMachine, assign } from 'xstate';

interface FormContext {
  email: string;
  password: string;
}

export const loginFormMachine = createMachine({
  id: 'loginForm',
  types: {} as {
    context: FormContext;
    events:
      | { type: 'SET_EMAIL'; value: string }
      | { type: 'SET_PASSWORD'; value: string }
      | { type: 'SUBMIT' };
  },
  initial: 'editing',
  context: { email: '', password: '' },
  states: {
    editing: {
      on: {
        SET_EMAIL: {
          actions: assign({ email: ({ event }) => event.value }),
        },
        SET_PASSWORD: {
          actions: assign({ password: ({ event }) => event.value }),
        },
        SUBMIT: [
          {
            target: 'submitting',
            guard: ({ context }) =>
              context.email.includes('@') && context.password.length >= 8,
          },
          {
            target: 'invalid',
          },
        ],
      },
    },
    invalid: {
      on: {
        SET_EMAIL: {
          target: 'editing',
          actions: assign({ email: ({ event }) => event.value }),
        },
        SET_PASSWORD: {
          target: 'editing',
          actions: assign({ password: ({ event }) => event.value }),
        },
      },
    },
    submitting: {
      // invoke submission service here
    },
  },
});

Hierarchical (Nested) States

export const playerMachine = createMachine({
  id: 'player',
  initial: 'stopped',
  states: {
    stopped: {
      on: { PLAY: 'playing' },
    },
    playing: {
      initial: 'normal',
      on: {
        STOP: 'stopped',
      },
      states: {
        normal: {
          on: { FAST_FORWARD: 'fastForward' },
        },
        fastForward: {
          on: { NORMAL: 'normal' },
        },
      },
    },
  },
});

// Check nested states
// state.matches('playing')           -> true when in any playing substate
// state.matches('playing.normal')    -> true only in normal substate
// state.matches('playing.fastForward') -> true only in fast forward

Parallel States

export const editorMachine = createMachine({
  id: 'editor',
  type: 'parallel',
  states: {
    bold: {
      initial: 'off',
      states: {
        off: { on: { TOGGLE_BOLD: 'on' } },
        on: { on: { TOGGLE_BOLD: 'off' } },
      },
    },
    italic: {
      initial: 'off',
      states: {
        off: { on: { TOGGLE_ITALIC: 'on' } },
        on: { on: { TOGGLE_ITALIC: 'off' } },
      },
    },
    underline: {
      initial: 'off',
      states: {
        off: { on: { TOGGLE_UNDERLINE: 'on' } },
        on: { on: { TOGGLE_UNDERLINE: 'off' } },
      },
    },
  },
});

// state.matches({ bold: 'on', italic: 'off' }) -> checks parallel substates

React Integration with @xstate/react

import { useMachine, useActor } from '@xstate/react';
import { authMachine } from '../machines/authMachine';

function LoginPage() {
  const [state, send] = useMachine(authMachine, {
    actors: {
      loginUser: fromPromise(async ({ input }) => {
        const res = await fetch('/api/auth/login', {
          method: 'POST',
          body: JSON.stringify(input),
          headers: { 'Content-Type': 'application/json' },
        });
        if (!res.ok) throw new Error('Invalid credentials');
        return res.json();
      }),
    },
  });

  if (state.matches('authenticated')) {
    return (
      <div>
        <p>Welcome, {state.context.user?.name}</p>
        <button onClick={() => send({ type: 'LOGOUT' })}>Logout</button>
      </div>
    );
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        const fd = new FormData(e.currentTarget);
        send({
          type: 'LOGIN',
          email: fd.get('email') as string,
          password: fd.get('password') as string,
        });
      }}
    >
      <input name="email" type="email" required />
      <input name="password" type="password" required />

      {state.matches('error') && (
        <p className="error">{state.context.error}</p>
      )}

      <button type="submit" disabled={state.matches('authenticating')}>
        {state.matches('authenticating') ? 'Signing in...' : 'Sign in'}
      </button>

      {state.matches('error') && state.context.attempts < 3 && (
        <button type="button" onClick={() => send({ type: 'RETRY' })}>
          Retry
        </button>
      )}
    </form>
  );
}

Actors (XState v5)

import { createMachine, fromPromise, assign } from 'xstate';

// Define an actor from a promise
const fetchUserActor = fromPromise(
  async ({ input }: { input: { userId: string } }) => {
    const res = await fetch(`/api/users/${input.userId}`);
    if (!res.ok) throw new Error('User not found');
    return res.json();
  }
);

// Use the actor in a machine
export const profileMachine = createMachine({
  id: 'profile',
  initial: 'loading',
  context: { user: null as User | null },
  states: {
    loading: {
      invoke: {
        src: fetchUserActor,
        input: ({ event }) => ({ userId: '123' }),
        onDone: {
          target: 'loaded',
          actions: assign({ user: ({ event }) => event.output }),
        },
        onError: 'error',
      },
    },
    loaded: {
      on: { REFRESH: 'loading' },
    },
    error: {
      on: { RETRY: 'loading' },
    },
  },
});

useSelector for Performance

import { useMachine, useSelector } from '@xstate/react';

function AuthStatus({ actorRef }: { actorRef: ActorRefFrom<typeof authMachine> }) {
  // Only re-renders when the selected value changes
  const isAuthenticated = useSelector(actorRef, (state) =>
    state.matches('authenticated')
  );
  const userName = useSelector(
    actorRef,
    (state) => state.context.user?.name
  );

  return isAuthenticated ? <span>{userName}</span> : <span>Guest</span>;
}

Visual Editor (Stately.ai)

XState machines can be designed visually at stately.ai/editor. The visual editor:

  • Renders statecharts as interactive diagrams
  • Generates XState v5 code that you paste into your project
  • Supports drag-and-drop state creation, transition drawing, and guard/action configuration
  • Syncs with GitHub repos for live visualization of existing machines
  • Simulates machine execution step by step for debugging

To visualize an existing machine, import it in the Stately editor or use the VS Code extension.

Best Practices

  1. Model states explicitly — if your UI has "loading," "error," and "success" modes, make them states in the machine, not boolean flags.
  2. Use state.matches() over string comparison — it handles nested and parallel states correctly.
  3. Keep machines pure — define side effects as actions and services, not inline in the machine config.
  4. Use the types property for TypeScript — declare context and event types via types: {} as { context: ...; events: ... }.
  5. Test machines without React — machines are pure logic; test transitions with machine.transition(state, event) in unit tests.
  6. Use useSelector for granular subscriptions — avoids re-rendering the entire component on every state change.
  7. Visualize early — sketch the statechart in the Stately editor before writing code to catch missing states and transitions.

Anti-Patterns

  1. Boolean soup instead of statesisLoading && !isError && !isSuccess is exactly what state machines prevent; use explicit states.
  2. Unreachable states — states without any incoming transitions are dead code; the visual editor helps catch these.
  3. Side effects in guards — guards should be pure predicates; use actions for side effects.
  4. Overly granular machines — not everything needs a state machine; simple toggles or counters work fine with useState.
  5. Ignoring the actor model — spawning child machines for independent concerns (like each form field) avoids monolithic machines.
  6. Skipping the visual editor — complex machines become hard to reason about from code alone; always visualize non-trivial machines.

Install this skill directly: skilldb add state-management-skills

Get CLI access →