Claude Code for State Machines: XState, Finite Automata, and Complex UI Flows — Claude Skills 360 Blog
Blog / Development / Claude Code for State Machines: XState, Finite Automata, and Complex UI Flows
Development

Claude Code for State Machines: XState, Finite Automata, and Complex UI Flows

Published: August 10, 2026
Read time: 9 min read
By: Claude Skills 360

Complex UI flows managed with boolean flags become unmanageable: isLoading && !isError && isSubmitting && hasRetried is impossible to reason about. State machines make impossible states unrepresentable — you can’t be in both loading and success simultaneously, and the machine defines exactly which transitions are valid from each state. Claude Code generates XState machines for complex flows and the React integration layer.

Why State Machines

Before XState, a multi-step form typically looks like:

// Before — boolean flag explosion
const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [canRetry, setCanRetry] = useState(false);
// What's the current "state"? It's ambiguous. You can be loading AND submitting.
// All 128 combinations of 7 booleans need to be reasoned about.

XState v5 Machine

Model a checkout flow as a state machine.
States: cart → address → payment → confirming → confirmed | failed.
Guard: can only proceed to payment if address is valid.
Retry: failed payment can be retried up to 3 times.
// machines/checkoutMachine.ts
import { createMachine, assign, fromPromise } from 'xstate';

interface CheckoutContext {
  cart: CartItem[];
  address: ShippingAddress | null;
  paymentMethod: PaymentMethod | null;
  orderId: string | null;
  error: string | null;
  retryCount: number;
}

export const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  types: {} as {
    context: CheckoutContext;
    events:
      | { type: 'PROCEED_TO_ADDRESS' }
      | { type: 'SET_ADDRESS'; address: ShippingAddress }
      | { type: 'PROCEED_TO_PAYMENT' }
      | { type: 'SET_PAYMENT'; paymentMethod: PaymentMethod }
      | { type: 'SUBMIT' }
      | { type: 'RETRY' }
      | { type: 'START_OVER' };
  },
  context: {
    cart: [],
    address: null,
    paymentMethod: null,
    orderId: null,
    error: null,
    retryCount: 0,
  },
  states: {
    cart: {
      on: {
        PROCEED_TO_ADDRESS: { target: 'address' },
      },
    },
    address: {
      on: {
        SET_ADDRESS: {
          actions: assign({ address: ({ event }) => event.address }),
        },
        PROCEED_TO_PAYMENT: {
          target: 'payment',
          guard: ({ context }) => context.address !== null, // Cannot proceed without address
        },
      },
    },
    payment: {
      on: {
        SET_PAYMENT: {
          actions: assign({ paymentMethod: ({ event }) => event.paymentMethod }),
        },
        SUBMIT: {
          target: 'confirming',
          guard: ({ context }) => context.paymentMethod !== null,
        },
      },
    },
    confirming: {
      invoke: {
        id: 'submitOrder',
        src: fromPromise(async ({ input }: { input: CheckoutContext }) => {
          const order = await createOrder({
            cart: input.cart,
            address: input.address!,
            paymentMethod: input.paymentMethod!,
          });
          return order;
        }),
        input: ({ context }) => context,
        onDone: {
          target: 'confirmed',
          actions: assign({ orderId: ({ event }) => event.output.id }),
        },
        onError: {
          target: 'failed',
          actions: assign({ error: ({ event }) => (event.error as Error).message }),
        },
      },
    },
    confirmed: {
      type: 'final',
    },
    failed: {
      on: {
        RETRY: {
          target: 'confirming',
          guard: ({ context }) => context.retryCount < 3,
          actions: assign({ retryCount: ({ context }) => context.retryCount + 1, error: null }),
        },
        START_OVER: {
          target: 'payment',
          actions: assign({ error: null, retryCount: 0 }),
        },
      },
    },
  },
});

React Integration

// components/Checkout.tsx
import { useMachine } from '@xstate/react';
import { checkoutMachine } from '../machines/checkoutMachine';

export function Checkout() {
  const [state, send] = useMachine(checkoutMachine);

  return (
    <div>
      {/* Progress indicator — derived from state */}
      <CheckoutProgress steps={['cart', 'address', 'payment', 'confirmed']} currentState={state.value as string} />

      {/* Render only the current step — no null checks for data */}
      {state.matches('cart') && (
        <CartStep
          cart={state.context.cart}
          onContinue={() => send({ type: 'PROCEED_TO_ADDRESS' })}
        />
      )}

      {state.matches('address') && (
        <AddressStep
          onSubmit={(address) => {
            send({ type: 'SET_ADDRESS', address });
            send({ type: 'PROCEED_TO_PAYMENT' });
          }}
        />
      )}

      {state.matches('payment') && (
        <PaymentStep
          onSubmit={(paymentMethod) => {
            send({ type: 'SET_PAYMENT', paymentMethod });
            send({ type: 'SUBMIT' });
          }}
        />
      )}

      {state.matches('confirming') && (
        <LoadingState message="Processing your order..." />
      )}

      {state.matches('confirmed') && (
        <SuccessState orderId={state.context.orderId!} />
      )}

      {state.matches('failed') && (
        <ErrorState
          error={state.context.error!}
          canRetry={state.context.retryCount < 3}
          onRetry={() => send({ type: 'RETRY' })}
          onStartOver={() => send({ type: 'START_OVER' })}
        />
      )}
    </div>
  );
}

Parallel States

The editor has two independent concerns: save status and connection status.
They should track independently but be visible simultaneously.
const editorMachine = createMachine({
  id: 'editor',
  type: 'parallel',
  states: {
    // Save status region
    save: {
      initial: 'idle',
      states: {
        idle: {
          on: { EDIT: 'unsaved' },
        },
        unsaved: {
          on: {
            EDIT: 'unsaved', // Stay unsaved on further edits
            SAVE: 'saving',
            AUTO_SAVE: 'saving',
          },
        },
        saving: {
          invoke: {
            src: fromPromise(saveDocument),
            onDone: { target: 'saved' },
            onError: { target: 'error' },
          },
        },
        saved: {
          on: { EDIT: 'unsaved' },
        },
        error: {
          on: { RETRY: 'saving' },
        },
      },
    },

    // Connection status region — independent of save
    connection: {
      initial: 'connected',
      states: {
        connected: {
          on: { DISCONNECT: 'disconnected' },
        },
        disconnected: {
          on: {
            RECONNECT: 'reconnecting',
          },
        },
        reconnecting: {
          invoke: {
            src: fromPromise(reconnect),
            onDone: { target: 'connected' },
            onError: { target: 'disconnected' },
          },
        },
      },
    },
  },
});

// Both states are tracked independently
// state.matches({ save: 'unsaved', connection: 'disconnected' }) — unsaved AND offline
// state.matches({ save: 'saving', connection: 'reconnecting' }) — saving while reconnecting

Actor Model

Model our application as communicating actors.
The checkout actor spawns a payment actor for the payment step.
import { createActor, sendTo, spawnChild } from 'xstate';

// Parent actor spawns child actor for isolated sub-flows
const checkoutMachineWithActors = createMachine({
  // ...
  states: {
    payment: {
      entry: assign({
        paymentActor: ({ spawn }) => spawn(paymentMachine),
      }),
      on: {
        PAYMENT_COMPLETE: {
          target: 'confirming',
          actions: assign({ paymentMethod: ({ event }) => event.paymentMethod }),
        },
      },
    },
  },
});

For managing global application state alongside these machine-local states, see the React query guide for server state and TanStack Query. For testing state machine transitions, see the testing guide. The Claude Skills 360 bundle includes state machine skill sets for XState, statechart design, and React integration. Start with the free tier to try state machine generation.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free