Finite State Modeling: The Architecture Pattern That Eliminates Impossible States

Security & Architecture

Consider a button that submits a form. It has states: idle, loading, success, error. In most implementations, these four states are stored as three separate booleans:

const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)

Three booleans give you $2^3 = 8$ possible state combinations. But only four of them are valid. The other four — isLoading && isSuccess, isLoading && isError, isSuccess && isError, isLoading && isSuccess && isError — are impossible states that can never actually occur in the real world but can absolutely occur in your code if updates are not perfectly synchronized.

Finite state machines make invalid states unrepresentable.

What Is a Finite State Machine?

A Finite State Machine (FSM) has:

  • A finite set of states — exactly one of which is active at any time
  • A set of events that can occur
  • Transitions that define which events are valid in which states and what state they lead to

For our button:

States: idle | loading | success | error
Events: SUBMIT | RESOLVE | REJECT | RESET

Transitions:
  idle + SUBMIT → loading
  loading + RESOLVE → success
  loading + REJECT → error
  success + RESET → idle
  error + RESET → idle

Any event that is not defined for the current state is ignored. No invalid state combination is possible.

Implementing FSM in React

The minimal approach uses a single string state instead of multiple booleans:

type State = 'idle' | 'loading' | 'success' | 'error'

function SubmitButton({ onSubmit }: { onSubmit: () => Promise<void> }) {
  const [state, setState] = useState<State>('idle')

  async function handleClick() {
    if (state !== 'idle') return // guard: only valid transition
    setState('loading')
    try {
      await onSubmit()
      setState('success')
    } catch {
      setState('error')
    }
  }

  return (
    <button onClick={handleClick} disabled={state === 'loading'}>
      {state === 'idle' && 'Submit'}
      {state === 'loading' && 'Submitting...'}
      {state === 'success' && 'Done!'}
      {state === 'error' && 'Try again'}
    </button>
  )
}

Only one state is active at all times. The boolean gymnastics are gone entirely.

Using useReducer for More Complex Machines

For machines with multiple pieces of data associated with states (like error messages or response data), useReducer is a natural fit:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; error: string }

type Event =
  | { type: 'SUBMIT' }
  | { type: 'RESOLVE'; data: User }
  | { type: 'REJECT'; error: string }

function reducer(state: State, event: Event): State {
  switch (state.status) {
    case 'idle':
      if (event.type === 'SUBMIT') return { status: 'loading' }
      return state
    case 'loading':
      if (event.type === 'RESOLVE') return { status: 'success', data: event.data }
      if (event.type === 'REJECT') return { status: 'error', error: event.error }
      return state
    default:
      return state
  }
}

The discriminated union type means TypeScript knows exactly which properties are available in each state. Accessing state.data is only valid when state.status === 'success' — the compiler enforces this.

XState: Full FSM Library

For complex machines (multi-step forms, wizard flows, nested states, parallel states), XState is the de facto library. It implements the full W3C SCXML spec and adds:

  • Hierarchical (nested) states
  • Parallel states that run concurrently
  • Guards (conditional transitions)
  • Side effects via actions and services
  • Visual inspector and state chart diagram
import { createMachine } from 'xstate'

const formMachine = createMachine({
  id: 'form',
  initial: 'idle',
  states: {
    idle: { on: { SUBMIT: 'loading' } },
    loading: {
      on: {
        RESOLVE: 'success',
        REJECT: 'error',
      }
    },
    success: { type: 'final' },
    error: { on: { RETRY: 'loading' } },
  }
})

The machine definition is a plain data structure — serializable, testable, and visualizable independently of any React code.

Why This Matters for UI Bugs

The classic frontend bugs that FSMs eliminate:

  • Showing a loading spinner and an error message at the same time
  • Submitting a form twice because the button wasn't disabled during the first request
  • Showing a "no results" message while results are still loading
  • A modal that can be both open and animating closed simultaneously

All of these are impossible state bugs — states that cannot exist in the real world but can exist in code that uses multiple loosely-coordinated boolean flags.

When NOT to Over-Engineer

Not every piece of state needs a state machine. A text input's value is just a value — no machine needed. FSMs are most valuable for:

  • Async operations (loading/success/error flows)
  • Multi-step workflows (wizards, onboarding)
  • Animations with discrete phases
  • Complex user interactions with multiple valid sequences

For a simple isOpen boolean on a dropdown, a state machine would be over-engineering.

Conclusion

Finite state machines are not academic theory — they are a practical architectural pattern that forces you to enumerate every state your UI can be in and every valid transition between them. The result is code where impossible states are simply unrepresentable at the type level and bugs that arise from invalid state combinations become unreachable by construction.