Finite State Modeling: The Architecture Pattern That Eliminates Impossible States
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.
