Front-end Engineering Lab

State Machines Advanced (XState)

Model complex workflows with finite state machines

State machines make impossible states impossible. They're perfect for complex workflows like checkout flows, multi-step forms, and connection management where certain transitions should never happen.

Why State Machines?

// ❌ BAD: Boolean soup (impossible states possible)
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
// Can be loading AND error AND success at once!

// ✅ GOOD: State machine (one state at a time)
type State = 'idle' | 'loading' | 'success' | 'error';
const [state, setState] = useState<State>('idle');

XState Setup

npm install xstate @xstate/react

Basic Machine

// machines/fetchMachine.ts
import { createMachine, assign } from 'xstate';

interface FetchContext {
  data: any;
  error: string | null;
}

type FetchEvent =
  | { type: 'FETCH' }
  | { type: 'RETRY' };

export const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: {
    data: null,
    error: null,
  } as FetchContext,
  states: {
    idle: {
      on: {
        FETCH: 'loading',
      },
    },
    loading: {
      invoke: {
        src: 'fetchData',
        onDone: {
          target: 'success',
          actions: assign({
            data: (_context, event) => event.data,
          }),
        },
        onError: {
          target: 'failure',
          actions: assign({
            error: (_context, event) => event.data,
          }),
        },
      },
    },
    success: {
      on: {
        FETCH: 'loading',
      },
    },
    failure: {
      on: {
        RETRY: 'loading',
      },
    },
  },
});

Using in React

// components/DataFetcher.tsx
import { useMachine } from '@xstate/react';
import { fetchMachine } from '@/machines/fetchMachine';

export function DataFetcher() {
  const [state, send] = useMachine(fetchMachine, {
    services: {
      fetchData: async () => {
        const response = await fetch('/api/data');
        if (!response.ok) throw new Error('Failed to fetch');
        return response.json();
      },
    },
  });

  return (
    <div>
      {state.matches('idle') && (
        <button onClick={() => send('FETCH')}>Fetch Data</button>
      )}
      
      {state.matches('loading') && <div>Loading...</div>}
      
      {state.matches('success') && (
        <div>
          <p>Data: {JSON.stringify(state.context.data)}</p>
          <button onClick={() => send('FETCH')}>Refresh</button>
        </div>
      )}
      
      {state.matches('failure') && (
        <div>
          <p>Error: {state.context.error}</p>
          <button onClick={() => send('RETRY')}>Retry</button>
        </div>
      )}
    </div>
  );
}

Multi-Step Form Machine

// machines/formMachine.ts
import { createMachine, assign } from 'xstate';

interface FormContext {
  step1Data: { name: string; email: string } | null;
  step2Data: { address: string; city: string } | null;
  step3Data: { payment: string } | null;
}

export const formMachine = createMachine({
  id: 'form',
  initial: 'step1',
  context: {
    step1Data: null,
    step2Data: null,
    step3Data: null,
  } as FormContext,
  states: {
    step1: {
      on: {
        NEXT: {
          target: 'step2',
          actions: assign({
            step1Data: (_context, event) => event.data,
          }),
        },
      },
    },
    step2: {
      on: {
        NEXT: {
          target: 'step3',
          actions: assign({
            step2Data: (_context, event) => event.data,
          }),
        },
        BACK: 'step1',
      },
    },
    step3: {
      on: {
        SUBMIT: 'submitting',
        BACK: 'step2',
      },
    },
    submitting: {
      invoke: {
        src: 'submitForm',
        onDone: 'success',
        onError: 'error',
      },
    },
    success: {
      type: 'final',
    },
    error: {
      on: {
        RETRY: 'submitting',
        BACK: 'step3',
      },
    },
  },
});

Guards (Conditional Transitions)

export const authMachine = createMachine({
  id: 'auth',
  initial: 'loggedOut',
  context: {
    attempts: 0,
    maxAttempts: 3,
  },
  states: {
    loggedOut: {
      on: {
        LOGIN: 'loggingIn',
      },
    },
    loggingIn: {
      invoke: {
        src: 'login',
        onDone: {
          target: 'loggedIn',
          actions: assign({ attempts: 0 }),
        },
        onError: [
          {
            target: 'locked',
            cond: 'maxAttemptsReached',
          },
          {
            target: 'loggedOut',
            actions: assign({
              attempts: (context) => context.attempts + 1,
            }),
          },
        ],
      },
    },
    loggedIn: {
      on: {
        LOGOUT: 'loggedOut',
      },
    },
    locked: {
      after: {
        60000: 'loggedOut', // Unlock after 1 minute
      },
    },
  },
}, {
  guards: {
    maxAttemptsReached: (context) => context.attempts >= context.maxAttempts - 1,
  },
});

Parallel States

// Multiple states active simultaneously
export const appMachine = createMachine({
  id: 'app',
  type: 'parallel',
  states: {
    auth: {
      initial: 'loggedOut',
      states: {
        loggedOut: { on: { LOGIN: 'loggedIn' } },
        loggedIn: { on: { LOGOUT: 'loggedOut' } },
      },
    },
    theme: {
      initial: 'light',
      states: {
        light: { on: { TOGGLE_THEME: 'dark' } },
        dark: { on: { TOGGLE_THEME: 'light' } },
      },
    },
    notifications: {
      initial: 'enabled',
      states: {
        enabled: { on: { DISABLE: 'disabled' } },
        disabled: { on: { ENABLE: 'enabled' } },
      },
    },
  },
});

Hierarchical (Nested) States

export const editorMachine = createMachine({
  id: 'editor',
  initial: 'reading',
  states: {
    reading: {
      on: {
        EDIT: 'editing',
      },
    },
    editing: {
      initial: 'normal',
      states: {
        normal: {
          on: {
            BOLD: 'bold',
            ITALIC: 'italic',
          },
        },
        bold: {
          on: {
            NORMAL: 'normal',
            ITALIC: 'boldItalic',
          },
        },
        italic: {
          on: {
            NORMAL: 'normal',
            BOLD: 'boldItalic',
          },
        },
        boldItalic: {
          on: {
            NORMAL: 'normal',
          },
        },
      },
      on: {
        SAVE: 'saving',
        CANCEL: 'reading',
      },
    },
    saving: {
      invoke: {
        src: 'saveDocument',
        onDone: 'reading',
        onError: 'editing',
      },
    },
  },
});

Visualization

// Visualize state machines at stately.ai/viz

import { createBrowserInspector } from '@stately/inspect';

const inspector = createBrowserInspector();

const [state, send] = useMachine(machine, {
  inspect: inspector.inspect,
});

Testing

import { interpret } from 'xstate';
import { fetchMachine } from './fetchMachine';

describe('fetchMachine', () => {
  it('transitions from idle to loading', () => {
    const service = interpret(fetchMachine).start();
    
    expect(service.state.value).toBe('idle');
    
    service.send('FETCH');
    
    expect(service.state.value).toBe('loading');
  });
});

Best Practices

  1. Model the domain: Match real-world states
  2. Use guards: Conditional transitions
  3. Actions for side effects: assign, log, etc.
  4. Services for async: invoke for API calls
  5. Visualize: Use XState Visualizer
  6. Test states: Verify impossible states can't happen
  7. Type context: Full TypeScript support
  8. Document transitions: Clear event names
  9. Keep machines small: Compose machines
  10. Use actors: For spawning child machines

State machines eliminate entire classes of bugs by making impossible states impossible!

On this page