References

React State Machines

Core Patterns

useReducer as a Minimal State Machine

useReducer with an explicit state union is a state machine — no library required. The reducer IS the transition function.

import { useReducer } from 'react';

// State union — only one state active at a time
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

type FetchEvent<T> =
  | { type: 'FETCH' }
  | { type: 'SUCCESS'; data: T }
  | { type: 'FAILURE'; error: string }
  | { type: 'RESET' };

function fetchReducer<T>(state: FetchState<T>, event: FetchEvent<T>): FetchState<T> {
  switch (state.status) {
    case 'idle':
      if (event.type === 'FETCH') return { status: 'loading' };
      break;
    case 'loading':
      if (event.type === 'SUCCESS') return { status: 'success', data: event.data };
      if (event.type === 'FAILURE') return { status: 'error', error: event.error };
      break;
    case 'success':
    case 'error':
      if (event.type === 'RESET') return { status: 'idle' };
      break;
  }
  return state; // invalid event for current state — no-op
}

// Hook wrapping the state machine
function useFetchMachine<T>(fetchFn: () => Promise<T>) {
  const [state, dispatch] = useReducer(fetchReducer<T>, { status: 'idle' });

  const fetch = async () => {
    if (state.status === 'loading') return; // guard: no concurrent fetches
    dispatch({ type: 'FETCH' });
    try {
      const data = await fetchFn();
      dispatch({ type: 'SUCCESS', data });
    } catch (err) {
      dispatch({ type: 'FAILURE', error: (err as Error).message });
    }
  };

  return { state, fetch, reset: () => dispatch({ type: 'RESET' }) };
}

// Usage in a component
function UserProfile({ userId }: { userId: string }) {
  const { state, fetch } = useFetchMachine(() =>
    fetch(`/api/users/${userId}`).then((r) => r.json())
  );

  return (
    <div>
      {state.status === 'idle'    && <button onClick={fetch}>Load User</button>}
      {state.status === 'loading' && <p>Loading...</p>}
      {state.status === 'success' && <p>Name: {state.data.name}</p>}
      {state.status === 'error'   && <p>Error: {state.error}</p>}
    </div>
  );
}

The reducer pattern makes impossible states unrepresentable: state.data only exists when status === 'success', enforced by TypeScript’s discriminated union.


Form Wizard Implementation (Multi-Step Form)

A form wizard has explicit states: which step is active, whether each step is valid, and the submission outcome. Modeling this as a machine eliminates currentStep === 3 && isLoading && hasError bugs.

import { useReducer } from 'react';

// Wizard states
type WizardState =
  | { status: 'filling';    step: 1 | 2 | 3; }
  | { status: 'validating'; step: 1 | 2 | 3; }
  | { status: 'submitting'; }
  | { status: 'success';    confirmationId: string }
  | { status: 'error';      error: string; step: 1 | 2 | 3 };

type WizardEvent =
  | { type: 'NEXT' }
  | { type: 'BACK' }
  | { type: 'VALIDATE_START' }
  | { type: 'VALIDATE_SUCCESS' }
  | { type: 'VALIDATE_FAILURE'; error: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; confirmationId: string }
  | { type: 'SUBMIT_FAILURE'; error: string }
  | { type: 'RETRY' };

function wizardReducer(state: WizardState, event: WizardEvent): WizardState {
  switch (state.status) {
    case 'filling':
      if (event.type === 'NEXT' && state.step < 3) {
        return { status: 'validating', step: state.step };
      }
      if (event.type === 'BACK' && state.step > 1) {
        return { status: 'filling', step: (state.step - 1) as 1 | 2 | 3 };
      }
      if (event.type === 'SUBMIT' && state.step === 3) {
        return { status: 'submitting' };
      }
      break;

    case 'validating':
      if (event.type === 'VALIDATE_SUCCESS') {
        const nextStep = (state.step + 1) as 1 | 2 | 3;
        return state.step < 3
          ? { status: 'filling', step: nextStep }
          : { status: 'submitting' };
      }
      if (event.type === 'VALIDATE_FAILURE') {
        return { status: 'error', error: event.error, step: state.step };
      }
      break;

    case 'error':
      if (event.type === 'RETRY') {
        return { status: 'filling', step: state.step };
      }
      break;

    case 'submitting':
      if (event.type === 'SUBMIT_SUCCESS') {
        return { status: 'success', confirmationId: event.confirmationId };
      }
      if (event.type === 'SUBMIT_FAILURE') {
        return { status: 'error', error: event.error, step: 3 };
      }
      break;
  }
  return state;
}

// Hook
function useFormWizard() {
  const [state, dispatch] = useReducer(wizardReducer, {
    status: 'filling',
    step: 1,
  });

  const next = () => dispatch({ type: 'NEXT' });
  const back = () => dispatch({ type: 'BACK' });

  const validateStep = async (validateFn: () => Promise<void>) => {
    dispatch({ type: 'VALIDATE_START' });
    try {
      await validateFn();
      dispatch({ type: 'VALIDATE_SUCCESS' });
    } catch (err) {
      dispatch({ type: 'VALIDATE_FAILURE', error: (err as Error).message });
    }
  };

  const submit = async (submitFn: () => Promise<string>) => {
    dispatch({ type: 'SUBMIT' });
    try {
      const confirmationId = await submitFn();
      dispatch({ type: 'SUBMIT_SUCCESS', confirmationId });
    } catch (err) {
      dispatch({ type: 'SUBMIT_FAILURE', error: (err as Error).message });
    }
  };

  return { state, next, back, validateStep, submit, retry: () => dispatch({ type: 'RETRY' }) };
}

// Render helper
function FormWizard() {
  const { state, next, back, validateStep, submit } = useFormWizard();

  if (state.status === 'success') {
    return <p>Order confirmed! ID: {state.confirmationId}</p>;
  }

  return (
    <div>
      {(state.status === 'filling' || state.status === 'validating' || state.status === 'error') && (
        <>
          <p>Step {state.step} of 3</p>
          {state.status === 'error' && <p className="error">{state.error}</p>}
          <button onClick={back} disabled={state.step === 1 || state.status !== 'filling'}>
            Back
          </button>
          <button onClick={() => validateStep(async () => { /* validate current step */ })}
            disabled={state.status === 'validating'}>
            {state.status === 'validating' ? 'Validating...' : 'Next'}
          </button>
        </>
      )}
      {state.status === 'submitting' && <p>Submitting...</p>}
    </div>
  );
}

Async Data Fetching State Machine (IDLE/LOADING/SUCCESS/ERROR)

A reusable hook that encodes the full async lifecycle as a state machine. Works for any async operation.

import { useReducer, useCallback, useRef } from 'react';

type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading'; requestId: string }
  | { status: 'success'; data: T; loadedAt: Date }
  | { status: 'error'; error: string; retryCount: number };

type AsyncEvent<T> =
  | { type: 'LOAD'; requestId: string }
  | { type: 'SUCCESS'; data: T; requestId: string }
  | { type: 'FAILURE'; error: string; requestId: string }
  | { type: 'RESET' }
  | { type: 'RETRY' };

function asyncReducer<T>(state: AsyncState<T>, event: AsyncEvent<T>): AsyncState<T> {
  switch (state.status) {
    case 'idle':
      if (event.type === 'LOAD') return { status: 'loading', requestId: event.requestId };
      break;
    case 'loading':
      // Only accept responses matching the current requestId — prevents stale updates
      if (event.type === 'SUCCESS' && event.requestId === state.requestId) {
        return { status: 'success', data: event.data, loadedAt: new Date() };
      }
      if (event.type === 'FAILURE' && event.requestId === state.requestId) {
        return { status: 'error', error: event.error, retryCount: 0 };
      }
      if (event.type === 'LOAD') return { status: 'loading', requestId: event.requestId };
      break;
    case 'success':
      if (event.type === 'LOAD') return { status: 'loading', requestId: event.requestId };
      if (event.type === 'RESET') return { status: 'idle' };
      break;
    case 'error':
      if (event.type === 'RETRY') {
        return { ...state, retryCount: state.retryCount + 1, status: 'idle' };
      }
      if (event.type === 'RESET') return { status: 'idle' };
      break;
  }
  return state;
}

function useAsyncMachine<T>(asyncFn: () => Promise<T>) {
  const [state, dispatch] = useReducer(asyncReducer<T>, { status: 'idle' });
  const requestCounter = useRef(0);

  const load = useCallback(async () => {
    const requestId = String(++requestCounter.current);
    dispatch({ type: 'LOAD', requestId });

    try {
      const data = await asyncFn();
      dispatch({ type: 'SUCCESS', data, requestId });
    } catch (err) {
      dispatch({ type: 'FAILURE', error: (err as Error).message, requestId });
    }
  }, [asyncFn]);

  return {
    state,
    load,
    retry: () => {
      dispatch({ type: 'RETRY' });
      load();
    },
    reset: () => dispatch({ type: 'RESET' }),
  };
}

// Usage
function UserList() {
  const { state, load, retry } = useAsyncMachine(() =>
    fetch('/api/users').then((r) => r.json())
  );

  return (
    <div>
      {state.status === 'idle' && (
        <button onClick={load}>Load Users</button>
      )}
      {state.status === 'loading' && <p>Loading...</p>}
      {state.status === 'success' && (
        <ul>
          {state.data.map((u: { id: string; name: string }) => (
            <li key={u.id}>{u.name}</li>
          ))}
        </ul>
      )}
      {state.status === 'error' && (
        <div>
          <p>Error: {state.error} (retry {state.retryCount})</p>
          <button onClick={retry}>Retry</button>
        </div>
      )}
    </div>
  );
}

The requestId guard prevents a classic React bug: a slow first request resolves after a fast second request, replacing newer data with stale data.


useMachine Hook from XState for React Integration

@xstate/react provides useMachine to connect an XState machine to a React component. The hook drives re-renders on state transitions and provides a typed send function.

npm install xstate @xstate/react
import { useMachine } from '@xstate/react';
import { createMachine, assign } from 'xstate';

// Define the machine (can be in a separate file)
const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: { on: { TOGGLE: 'active' } },
    active:   { on: { TOGGLE: 'inactive' } },
  },
});

// Minimal useMachine usage
function Toggle() {
  const [state, send] = useMachine(toggleMachine);

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

With context and async actions (checkout example from xstate-patterns.md):

import { useMachine } from '@xstate/react';
import { checkoutMachine } from './checkout-machine';

function CheckoutPage({ cart }: { cart: CartItem[] }) {
  const [state, send] = useMachine(checkoutMachine, {
    // Provide initial context via input
    input: { cart, promoCode: null, discount: 0, paymentMethod: null, error: null },
  });

  // state.value — current state name: 'cart' | 'processing' | 'success' | 'failed'
  // state.context — typed context object
  // state.matches('cart') — boolean: is the machine in 'cart' state?
  // state.can({ type: 'SUBMIT' }) — boolean: is SUBMIT a valid event right now?

  return (
    <div>
      {state.matches('cart') && (
        <div>
          <p>Total: ${state.context.cart.reduce((s, i) => s + i.price * i.qty, 0)}</p>
          <button onClick={() => send({ type: 'SELECT_PAYMENT', method: 'card' })}>
            Pay with Card
          </button>
          <button
            onClick={() => send({ type: 'SUBMIT' })}
            disabled={!state.can({ type: 'SUBMIT' })}
          >
            Place Order
          </button>
        </div>
      )}

      {state.matches('processing') && <p>Processing payment...</p>}

      {state.matches('success') && <p>Order placed successfully!</p>}

      {state.matches('failed') && (
        <div>
          <p>Payment failed: {state.context.error}</p>
          <button onClick={() => send({ type: 'RETRY' })}>Try Again</button>
        </div>
      )}
    </div>
  );
}

useSelector for performance — only re-render when specific state slice changes:

import { useSelector } from '@xstate/react';
import { AnyActorRef } from 'xstate';

function OrderStatus({ actor }: { actor: AnyActorRef }) {
  // Only re-renders when state.value changes — not on every context update
  const stateName = useSelector(actor, (snapshot) => snapshot.value);
  const error     = useSelector(actor, (snapshot) => snapshot.context.error);

  return (
    <div>
      <span>Status: {String(stateName)}</span>
      {error && <span className="error">{error}</span>}
    </div>
  );
}

When to use useMachine vs useReducer:

ConcernuseReduceruseMachine
DependenciesNonexstate, @xstate/react
Async invokeManualBuilt-in (invoke)
Parallel statesManualBuilt-in (type: 'parallel')
Delayed transitionsManualBuilt-in (after)
VisualizationNoneStately.ai inspector
Complexity threshold2-4 states5+ states, or need invoke/parallel