Skip to content
SP StackPractices
advanced By Mathias Paulenko

State Machine UI: Finite State Machines for UI

How to model UI state transitions with finite state machines in React. Covers XState, statecharts, guarded transitions, and preventing impossible states.

Topics: frontend

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Overview

A finite state machine (FSM) models UI behavior as a set of states and transitions between them. Instead of scattered boolean flags (isLoading, isError, isSuccess, data), you have a single state value that can only be one of a fixed set of states. Transitions are explicit — you define exactly which events can move from which states. This prevents impossible states (loading and error at the same time), makes the UI behavior predictable, and provides a visual model of the component’s behavior. XState is the most popular FSM library for React, but the pattern works with any state management approach.

When to Use

  • Multi-step forms with complex state transitions (wizard, checkout flow)
  • Components with many boolean flags that create impossible combinations
  • Interactive components with clear states (idle, loading, success, error, retry)
  • UI flows that need to be testable and predictable
  • Components where business rules govern state transitions

When NOT to Use

  • Simple components with 1-2 states — a boolean is fine
  • Components where state is just data (a list, a filter) — use useState or useReducer
  • Applications already committed to a different state management approach
  • Prototypes where the state model is still evolving

Solution

Basic state machine with useReducer

// useFormMachine.js — simple FSM with useReducer
import { useReducer, useCallback } from 'react';

const states = {
  IDLE: 'idle',
  SUBMITTING: 'submitting',
  SUCCESS: 'success',
  ERROR: 'error',
};

const initialState = { state: states.IDLE, data: null, error: null };

function formReducer(state, action) {
  switch (state.state) {
    case states.IDLE:
      if (action.type === 'SUBMIT') {
        return { state: states.SUBMITTING, data: null, error: null };
      }
      return state;

    case states.SUBMITTING:
      if (action.type === 'SUCCESS') {
        return { state: states.SUCCESS, data: action.data, error: null };
      }
      if (action.type === 'ERROR') {
        return { state: states.ERROR, data: null, error: action.error };
      }
      return state;

    case states.SUCCESS:
      if (action.type === 'RESET') {
        return initialState;
      }
      return state;

    case states.ERROR:
      if (action.type === 'RETRY') {
        return { state: states.SUBMITTING, data: null, error: null };
      }
      if (action.type === 'RESET') {
        return initialState;
      }
      return state;

    default:
      return state;
  }
}

function useFormMachine(submitFn) {
  const [state, dispatch] = useReducer(formReducer, initialState);

  const submit = useCallback(async (formData) => {
    dispatch({ type: 'SUBMIT' });
    try {
      const data = await submitFn(formData);
      dispatch({ type: 'SUCCESS', data });
    } catch (error) {
      dispatch({ type: 'ERROR', error: error.message });
    }
  }, [submitFn]);

  const retry = useCallback(() => dispatch({ type: 'RETRY' }), []);
  const reset = useCallback(() => dispatch({ type: 'RESET' }), []);

  return { ...state, submit, retry, reset };
}

export { states, useFormMachine };
// ContactForm.jsx — using the form machine
import { useFormMachine, states } from '../hooks/useFormMachine';

function ContactForm() {
  const { state, data, error, submit, retry, reset } = useFormMachine(
    async (formData) => {
      const response = await fetch('/api/contact', {
        method: 'POST',
        body: formData,
      });
      if (!response.ok) throw new Error('Failed to submit');
      return response.json();
    }
  );

  if (state === states.SUCCESS) {
    return (
      <div className="success">
        <h2>Thank you!</h2>
        <p>{data.message}</p>
        <button onClick={reset}>Send another</button>
      </div>
    );
  }

  if (state === states.ERROR) {
    return (
      <div className="error">
        <h2>Submission failed</h2>
        <p>{error}</p>
        <button onClick={retry}>Try again</button>
        <button onClick={reset}>Start over</button>
      </div>
    );
  }

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      submit(new FormData(e.target));
    }}>
      <input name="email" type="email" required placeholder="Email" />
      <textarea name="message" required placeholder="Message" />
      <button type="submit" disabled={state === states.SUBMITTING}>
        {state === states.SUBMITTING ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}

XState machine for a data fetcher

// fetchMachine.js — XState machine for data fetching
import { createMachine, assign } from 'xstate';

const fetchMachine = createMachine({
  id: 'fetch',
  initial: 'idle',
  context: {
    data: null,
    error: null,
    retries: 0,
  },
  states: {
    idle: {
      on: {
        FETCH: 'loading',
      },
    },
    loading: {
      invoke: {
        src: 'fetchData',
        onDone: {
          target: 'success',
          actions: assign({ data: (_, event) => event.data, error: null }),
        },
        onError: {
          target: 'error',
          actions: assign({
            error: (_, event) => event.data.message,
            retries: (context) => context.retries + 1,
          }),
        },
      },
      on: {
        CANCEL: 'idle',
      },
    },
    success: {
      on: {
        REFETCH: 'loading',
        RESET: 'idle',
      },
    },
    error: {
      on: {
        RETRY: {
          target: 'loading',
          cond: (context) => context.retries < 3,
        },
        RESET: 'idle',
      },
    },
  },
});

export default fetchMachine;
// DataFetcher.jsx — using XState with React
import { useMachine } from '@xstate/react';
import fetchMachine from '../machines/fetchMachine';

function DataFetcher({ url }) {
  const [state, send] = useMachine(fetchMachine, {
    services: {
      fetchData: () => fetch(url).then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      }),
    },
  });

  const { data, error, retries } = state.context;

  if (state.matches('idle')) {
    return <button onClick={() => send('FETCH')}>Load data</button>;
  }

  if (state.matches('loading')) {
    return (
      <div>
        <div className="spinner">Loading...</div>
        <button onClick={() => send('CANCEL')}>Cancel</button>
      </div>
    );
  }

  if (state.matches('success')) {
    return (
      <div>
        <pre>{JSON.stringify(data, null, 2)}</pre>
        <button onClick={() => send('REFETCH')}>Refresh</button>
      </div>
    );
  }

  if (state.matches('error')) {
    return (
      <div className="error">
        <p>Error: {error}</p>
        <p>Attempts: {retries}</p>
        {retries < 3 ? (
          <button onClick={() => send('RETRY')}>Retry</button>
        ) : (
          <p>Max retries reached.</p>
        )}
        <button onClick={() => send('RESET')}>Reset</button>
      </div>
    );
  }

  return null;
}

Multi-step wizard with XState

// checkoutMachine.js — checkout flow state machine
import { createMachine, assign } from 'xstate';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  context: {
    cart: [],
    shippingAddress: null,
    paymentMethod: null,
    order: null,
    error: null,
  },
  states: {
    cart: {
      on: {
        NEXT: 'shipping',
        REMOVE_ITEM: {
          actions: assign({
            cart: (ctx, event) => ctx.cart.filter(item => item.id !== event.id),
          }),
        },
      },
    },
    shipping: {
      on: {
        NEXT: {
          target: 'payment',
          cond: (ctx) => ctx.shippingAddress !== null,
        },
        BACK: 'cart',
        SET_ADDRESS: {
          actions: assign({
            shippingAddress: (_, event) => event.address,
          }),
        },
      },
    },
    payment: {
      on: {
        NEXT: {
          target: 'review',
          cond: (ctx) => ctx.paymentMethod !== null,
        },
        BACK: 'shipping',
        SET_PAYMENT: {
          actions: assign({
            paymentMethod: (_, event) => event.method,
          }),
        },
      },
    },
    review: {
      on: {
        SUBMIT: 'processing',
        BACK: 'payment',
      },
    },
    processing: {
      invoke: {
        src: 'submitOrder',
        onDone: {
          target: 'confirmation',
          actions: assign({ order: (_, event) => event.data }),
        },
        onError: {
          target: 'error',
          actions: assign({ error: (_, event) => event.data.message }),
        },
      },
    },
    confirmation: {
      type: 'final',
    },
    error: {
      on: {
        RETRY: 'processing',
        BACK: 'review',
      },
    },
  },
});

export default checkoutMachine;
// Checkout.jsx — multi-step checkout using the machine
import { useMachine } from '@xstate/react';
import checkoutMachine from '../machines/checkoutMachine';

function Checkout({ initialCart }) {
  const [state, send] = useMachine(checkoutMachine, {
    context: { cart: initialCart },
    services: {
      submitOrder: (context) =>
        fetch('/api/orders', {
          method: 'POST',
          body: JSON.stringify({
            cart: context.cart,
            shipping: context.shippingAddress,
            payment: context.paymentMethod,
          }),
        }).then(res => res.json()),
    },
  });

  if (state.matches('cart')) {
    return (
      <div>
        <h2>Your Cart</h2>
        {state.context.cart.map(item => (
          <div key={item.id}>
            {item.name} — ${item.price}
            <button onClick={() => send({ type: 'REMOVE_ITEM', id: item.id })}>
              Remove
            </button>
          </div>
        ))}
        <button onClick={() => send('NEXT')}>Continue to Shipping</button>
      </div>
    );
  }

  if (state.matches('shipping')) {
    return (
      <div>
        <h2>Shipping Address</h2>
        <AddressForm
          onSave={(address) => send({ type: 'SET_ADDRESS', address })}
        />
        <button onClick={() => send('BACK')}>Back to Cart</button>
        <button
          onClick={() => send('NEXT')}
          disabled={!state.context.shippingAddress}
        >
          Continue to Payment
        </button>
      </div>
    );
  }

  if (state.matches('payment')) {
    return (
      <div>
        <h2>Payment Method</h2>
        <PaymentForm
          onSave={(method) => send({ type: 'SET_PAYMENT', method })}
        />
        <button onClick={() => send('BACK')}>Back to Shipping</button>
        <button
          onClick={() => send('NEXT')}
          disabled={!state.context.paymentMethod}
        >
          Review Order
        </button>
      </div>
    );
  }

  if (state.matches('review')) {
    return (
      <div>
        <h2>Review Your Order</h2>
        <pre>{JSON.stringify(state.context, null, 2)}</pre>
        <button onClick={() => send('BACK')}>Back to Payment</button>
        <button onClick={() => send('SUBMIT')}>Place Order</button>
      </div>
    );
  }

  if (state.matches('processing')) {
    return <div className="spinner">Processing your order...</div>;
  }

  if (state.matches('confirmation')) {
    return (
      <div className="confirmation">
        <h2>Order Confirmed!</h2>
        <p>Order #{state.context.order.id}</p>
      </div>
    );
  }

  if (state.matches('error')) {
    return (
      <div className="error">
        <h2>Order Failed</h2>
        <p>{state.context.error}</p>
        <button onClick={() => send('RETRY')}>Try Again</button>
        <button onClick={() => send('BACK')}>Back to Review</button>
      </div>
    );
  }

  return null;
}

Toggle component with FSM

// toggleMachine.js — simple toggle machine
import { createMachine } from 'xstate';

const toggleMachine = createMachine({
  id: 'toggle',
  initial: 'inactive',
  states: {
    inactive: {
      on: { TOGGLE: 'active' },
    },
    active: {
      on: { TOGGLE: 'inactive' },
    },
  },
});

export default toggleMachine;
// Dropdown.jsx — dropdown with proper state management
import { createMachine, assign } from 'xstate';
import { useMachine } from '@xstate/react';

const dropdownMachine = createMachine({
  id: 'dropdown',
  initial: 'closed',
  context: { selectedIndex: 0 },
  states: {
    closed: {
      on: {
        OPEN: 'open',
        TOGGLE: 'open',
      },
    },
    open: {
      on: {
        CLOSE: 'closed',
        TOGGLE: 'closed',
        SELECT: {
          target: 'closed',
          actions: assign({
            selectedIndex: (_, event) => event.index,
          }),
        },
        CLICK_OUTSIDE: 'closed',
        ESCAPE: 'closed',
      },
    },
  },
});

function Dropdown({ items }) {
  const [state, send] = useMachine(dropdownMachine);
  const selectedItem = items[state.context.selectedIndex];

  return (
    <div className="dropdown" tabIndex={0}>
      <button
        onClick={() => send('TOGGLE')}
        onKeyDown={(e) => e.key === 'Escape' && send('ESCAPE')}
      >
        {selectedItem.label} {state.matches('open') ? '▲' : '▼'}
      </button>

      {state.matches('open') && (
        <ul className="dropdown-menu">
          {items.map((item, index) => (
            <li
              key={item.id}
              onClick={() => send({ type: 'SELECT', index })}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Guarded transitions

// authMachine.js — guarded transitions with conditions
import { createMachine, assign } from 'xstate';

const authMachine = createMachine({
  id: 'auth',
  initial: 'unauthenticated',
  context: {
    user: null,
    token: null,
    loginAttempts: 0,
  },
  states: {
    unauthenticated: {
      on: {
        LOGIN: 'authenticating',
      },
    },
    authenticating: {
      invoke: {
        src: 'authenticate',
        onDone: {
          target: 'authenticated',
          actions: assign({
            user: (_, event) => event.data.user,
            token: (_, event) => event.data.token,
            loginAttempts: 0,
          }),
        },
        onError: {
          target: 'authError',
          actions: assign({
            loginAttempts: (ctx) => ctx.loginAttempts + 1,
          }),
        },
      },
    },
    authenticated: {
      on: {
        LOGOUT: {
          target: 'unauthenticated',
          actions: assign({ user: null, token: null }),
        },
      },
    },
    authError: {
      on: {
        RETRY: {
          target: 'authenticating',
          cond: (ctx) => ctx.loginAttempts < 3,
        },
        BACK: 'unauthenticated',
      },
    },
  },
});

export default authMachine;

Variants

Hierarchical state machine

// fileUploadMachine.js — nested states
import { createMachine, assign } from 'xstate';

const uploadMachine = createMachine({
  id: 'upload',
  initial: 'idle',
  context: { progress: 0, file: null, url: null },
  states: {
    idle: {
      on: { SELECT_FILE: { target: 'selected', actions: assign({ file: (_, e) => e.file }) } },
    },
    selected: {
      on: { START: 'uploading', CANCEL: 'idle' },
    },
    uploading: {
      initial: 'sending',
      states: {
        sending: {
          on: {
            PROGRESS: { actions: assign({ progress: (_, e) => e.progress }) },
            COMPLETE: { target: 'done', actions: assign({ url: (_, e) => e.url }) },
            FAIL: 'failed',
          },
        },
        failed: {
          on: { RETRY: 'sending' },
        },
        done: { type: 'final' },
      },
      on: { CANCEL: 'idle' },
    },
  },
});

Actor model for concurrent state machines

// concurrentUploads.js — multiple file uploads as actors
import { createMachine, spawn, assign } from 'xstate';
import uploadMachine from './fileUploadMachine';

const batchUploadMachine = createMachine({
  id: 'batchUpload',
  initial: 'idle',
  context: { uploads: [] },
  states: {
    idle: {
      on: {
        ADD_FILES: {
          actions: assign({
            uploads: (ctx, event) =>
              event.files.map(file => ({
                file,
                ref: spawn(uploadMachine.withContext({ file })),
              })),
          }),
        },
      },
    },
  },
});

Best Practices

  • For a deeper guide, see Optimistic Update: Update UI Immediately, Reconcile on.

  • Model states, not flags — replace isLoading && !isError with a single state.matches('loading')

  • Define all transitions explicitly — if an event isn’t listed for a state, it’s ignored. This prevents bugs.

  • Use guards for conditional transitions — cond: (ctx) => ctx.attempts < 3 prevents infinite retries

  • Use hierarchical states for complex flows — nested states keep the machine readable

  • Keep machines small and focused — one machine per component or flow, not one giant machine

  • Visualize the machine — XState Stencil or the visualizer at stately.ai help you see the flow

  • Use type: 'final' for terminal states — the machine signals when it’s done

  • Test the machine — XState machines are testable without React. Test transitions directly.

Common Mistakes

  • Using booleans instead of states: isLoading, isError, isSuccess can all be true at once. A state machine makes this impossible.
  • Not handling all events: forgetting to handle CANCEL during loading. The user clicks cancel and nothing happens.
  • Side effects in transitions: making API calls inside actions. Use invoke for async operations — it’s tracked by the machine.
  • One giant machine: modeling the entire app as one machine. Split into smaller machines per feature or component.
  • Not using guards: allowing unlimited retries. Guards enforce business rules like “max 3 attempts.”

FAQ

What is a finite state machine in UI?

A model where the UI can be in exactly one state at a time (idle, loading, success, error). Events trigger transitions between states. Only defined transitions are allowed, preventing impossible state combinations.

Why use XState over useReducer?

XState gives you visual debugging, hierarchical states, guarded transitions, invoked services, and actor model support. useReducer is simpler but doesn’t prevent invalid transitions or provide a visual model.

What are impossible states?

Combinations of flags that can’t logically coexist but aren’t prevented by the code. Example: isLoading: true, isError: true, data: [...] — are we loading, erroring, or showing data? A state machine makes this impossible.

Should I use a state machine for every component?

No. Simple toggles, counters, and single-value states don’t need a machine. Use FSMs when you have multiple related states, complex transitions, or business rules governing state changes.

Can I use state machines without a library?

Yes. useReducer with a switch statement is a basic state machine. The library (XState) adds visualization, guards, hierarchical states, and testing tools. Start with useReducer and upgrade to XState when complexity grows.