Modeling State Logic Cleanly
State machines model allowed states and transitions explicitly, instead of leaving them to be inferred implicitly from scattered boolean flags. XState brings this concept to JavaScript as a framework-independent library, with states, events, guards and hierarchical statecharts that work equally well in React, Vue or plain JavaScript.
Table of Contents
- 1. Why State Machines Model UI Logic Better
- 2. Core Concepts: States, Events, Transitions
- 3. XState Basics: createMachine and Statecharts
- 4. Guards and Actions: Conditions and Side Effects
- 5. Hierarchical and Parallel States
- 6. Context: Extended State Alongside Finite States
- 7. Integration Into React, Vue and Vanilla JavaScript
- 8. Testing State Machines
- 9. State Machines Compared
- 10. Summary
- 11. FAQ
1. Why State Machines Model UI Logic Better
A state machine defines a finite set of allowed states along with the events permitted to trigger transitions between them. The alternative found in many codebases is several independent boolean flags such as isLoading, hasError and isSuccess, which theoretically allow 2 to the power of 3 combinations, even though only four of them are actually meaningful. A state machine makes these implicit rules explicit by allowing only the actually valid states, such as idle, loading, success and error, to exist at all.
This difference becomes especially visible with complex forms, checkout flows or file uploads, where impossible states such as isLoading and hasError being true at once lead in practice to contradictory UI states, such as a spinner next to an error message. A state machine structurally prevents such contradictions, because a state is always unambiguous and a transition must always be explicitly defined, otherwise the event is simply ignored.
XState is the most widely used JavaScript library for state machines and implements the SCXML-inspired statechart model by David Harel, extended with hierarchical and parallel states. The following sections walk through building a state machine, from the core concepts through guards and context to integration into existing frontend applications.
2. Core Concepts: States, Events, Transitions
The three core terms of every state machine are states, events and transitions. A state describes a named configuration of the system at a given point in time, such as idle or submitting. An event is a named signal sent to the machine from outside, such as SUBMIT or RETRY. A transition connects a state with an event and defines the target state the machine moves to when that event arrives while in that state.
Crucially, a state machine only processes events that are explicitly defined for the current state. If a SUBMIT event arrives while the machine is already in the submitting state and no transition was defined for it, the machine stays unchanged in the submitting state and the event is discarded. This behavior fundamentally distinguishes state machines from event emitters, which process every event regardless of the current state.
3. XState Basics: createMachine and Statecharts
XState defines a state machine declaratively through createMachine() with a configuration object describing states, the initial state and transitions as a nested structure. This declarativeness is a key advantage: the entire state logic can be visualized as a diagram, for example in the XState visualizer, and is readable independently of the rendering code. A developer can review the statechart definition without knowing the rest of the application code.
The interpret() call, createActor() in XState 5, creates a runnable instance of the state machine, called an actor, which accepts events via send() and reports state changes outward via subscribe(). This actor is framework independent, it works identically in a Node.js CLI, a React component or a Vue composable.
// Basic state machine: fetch flow with idle, loading, success, error
import { createMachine, createActor, assign } from 'xstate';
const fetchMachine = createMachine({
id: 'fetch',
initial: 'idle',
context: { data: null, error: null },
states: {
idle: {
on: { FETCH: 'loading' },
},
loading: {
on: {
RESOLVE: { target: 'success', actions: assign({ data: ({ event }) => event.data }) },
REJECT: { target: 'error', actions: assign({ error: ({ event }) => event.error }) },
},
},
success: {
on: { FETCH: 'loading' },
},
error: {
on: { RETRY: 'loading' },
},
},
});
const actor = createActor(fetchMachine);
actor.subscribe((snapshot) => console.log(snapshot.value));
actor.start();
actor.send({ type: 'FETCH' }); // -> loading
4. Guards and Actions: Conditions and Side Effects
A guard is a pure function that checks before a transition whether it is actually allowed to happen, based on the current context or event data. Only once the guard returns true does the state machine execute the transition. Without a satisfied guard, the machine stays in its current state even if the matching event arrives, an important tool for conditional transitions such as form validation before submit.
Actions, on the other hand, are side effects executed on a transition, such as updating context through assign(), calling an external API, or logging an event. XState distinguishes between entry actions, which run when entering a state, exit actions on leaving, and transition actions, executed precisely during the transition. This clean separation of condition (guard) and effect (action) makes state machines in XState particularly easy to follow in code reviews.
// Guards and actions: conditional transition with validation
const formMachine = createMachine({
id: 'form',
initial: 'editing',
context: { email: '' },
states: {
editing: {
on: {
SUBMIT: [
{
guard: ({ context }) => context.email.includes('@'),
target: 'submitting',
},
{ target: 'invalid' }, // fallback when guard fails
],
},
},
invalid: {
entry: () => console.warn('Validation failed'),
on: { EDIT: 'editing' },
},
submitting: {
entry: () => console.log('Submitting form...'),
on: { SUCCESS: 'done' },
},
done: { type: 'final' },
},
});
5. Hierarchical and Parallel States
Hierarchical states, also called compound states in XState, let you divide a state into nested sub-states that inherit shared transitions from the parent state. A state authenticated can, for example, contain the sub-states dashboard and settings, while a higher-level transition LOGOUT is defined at the parent level and works from every sub-state without duplicating it individually. This structure significantly reduces redundancy compared to a flat state machine with many similar transitions.
Parallel states go a step further: several independent sub-machines run simultaneously within the same parent state, each with its own current state. A typical example is a video player, where the playback state (playing/paused) and the volume state (muted/unmuted) exist independently of each other but are part of the same state machine. Without parallel states, you would have to model the cartesian product of all combinations as individual states, which quickly becomes unmanageable.
// Hierarchical (compound) and parallel states
const playerMachine = createMachine({
id: 'player',
type: 'parallel', // both regions run simultaneously
states: {
playback: {
initial: 'paused',
states: {
paused: { on: { PLAY: 'playing' } },
playing: { on: { PAUSE: 'paused' } },
},
},
volume: {
initial: 'unmuted',
states: {
unmuted: { on: { MUTE: 'muted' } },
muted: { on: { UNMUTE: 'unmuted' } },
},
},
},
});
// A shared LOGOUT transition on a parent state, inherited by every child
const sessionMachine = createMachine({
id: 'session',
initial: 'authenticated',
states: {
authenticated: {
on: { LOGOUT: 'anonymous' }, // works from dashboard AND settings
initial: 'dashboard',
states: {
dashboard: { on: { OPEN_SETTINGS: 'settings' } },
settings: { on: { CLOSE_SETTINGS: 'dashboard' } },
},
},
anonymous: { on: { LOGIN: 'authenticated' } },
},
});
6. Context: Extended State Alongside Finite States
Besides finite states, almost every real state machine needs additional data that should not be modeled as its own state, such as the content of a form field or the number of failed attempts. For that, XState offers context, an arbitrary data object updated through assign() actions, while the finite states continue to define the overall structure of the flow.
This separation between finite states for flow structure and context for extended data is crucial for readability: the statechart visualization still shows only a manageable number of states, while context can carry arbitrarily complex data without exploding the number of states. Retry counters that force a different state after three attempts are a classic pattern where context and guards work together.
// Context alongside finite states: retry counter driving a guard
const uploadMachine = createMachine({
id: 'upload',
initial: 'idle',
context: { retries: 0, maxRetries: 3 },
states: {
idle: { on: { UPLOAD: 'uploading' } },
uploading: {
on: {
SUCCESS: 'done',
FAILURE: [
{
guard: ({ context }) => context.retries < context.maxRetries,
target: 'uploading',
actions: assign({ retries: ({ context }) => context.retries + 1 }),
},
{ target: 'failed' }, // retries exhausted
],
},
},
done: { type: 'final' },
failed: { on: { RESET: 'idle' } },
},
});
7. Integration Into React, Vue and Vanilla JavaScript
The useMachine() hook from @xstate/react binds a state machine to a React component, returning the current snapshot and a send function, and automatically synchronizes re-renders with state changes. In Vue, @xstate/vue handles the same task with useMachine(), connected to Vue's reactivity system via ref. Both adapters are thin wrappers around the same framework-independent actor core.
In plain vanilla JavaScript or Web Components, the same actor can be used directly through createActor(machine).subscribe(), with no extra adapter. This consistency is the practical advantage of XState over framework-specific state solutions: the same state machine definition can be reused unchanged in a React app, a Vue app and a plain vanilla script, because the state logic is completely decoupled from rendering.
// React integration: useMachine binds the same actor core to a component
import { useMachine } from '@xstate/react';
function UploadButton() {
const [snapshot, send] = useMachine(uploadMachine);
return (
<button onClick={() => send({ type: 'UPLOAD' })}>
{snapshot.matches('uploading') ? 'Uploading...' : 'Upload'}
</button>
);
}
// Vanilla JavaScript, no framework adapter needed
const actor = createActor(uploadMachine);
actor.subscribe((snapshot) => {
document.querySelector('#status').textContent = snapshot.value;
});
actor.start();
8. Testing State Machines
State machines can be tested in isolation from rendering by creating an actor, sending events and checking the resulting state, with no DOM or testing library setup at all. A test for the transitions FETCH to loading, then RESOLVE to success, runs in milliseconds and covers the complete transition logic, regardless of how the UI is later rendered.
XState additionally offers model-based testing through @xstate/test, which automatically generates test paths from the statechart definition, covering every possible state and every possible transition at least once. This approach surfaces edge cases frequently missed in manually written tests, because path generation happens systematically rather than intuitively, a clear advantage over ad hoc tests of boolean-flag-based logic.
9. State Machines Compared
The choice between an explicit state machine and scattered boolean flags or a generic Redux reducer depends on the complexity of the flow: simple on/off states barely benefit, complex flows with many possible transitions benefit considerably.
| Approach | Impossible States | Visualizability | Best Fit |
|---|---|---|---|
| Boolean flags | Possible, common problem | None | Very simple, isolated states |
| Redux reducer | Possible, without discipline | Low | Generic global state |
| XState state machine | Structurally excluded | Statechart diagram | Complex flows with clear transitions |
| Enum + switch | Partially excluded | Low | Medium complexity without a library |
In practice, the value of a state machine shows up especially in checkout processes, multi-step forms and file uploads, where the number of possible state combinations grows exponentially with every additional boolean flag. XState makes this complexity explicit and testable instead of leaving it scattered implicitly throughout the code.
Mironsoft
State logic, checkout flows and complex UI processes
Checkout logic without contradictory states?
We model complex UI flows as XState state machines, with clear guards, testable transitions and statechart diagrams the whole team understands.
Flow audit
Analysis of existing boolean-flag logic for impossible state combinations
XState migration
Turning critical flows into tested, visualizable state machines
Team training
Hands-on statechart concepts, taught directly on your own codebase
10. Summary
State machines replace implicit, scattered boolean flags with explicitly defined states, events and transitions that structurally exclude impossible state combinations. XState implements this model as a framework-independent library with hierarchical and parallel states, guards for conditions, actions for side effects, and context for extended data alongside the finite states themselves.
The practical benefit shows up most strongly in complex flows like checkout processes, multi-step forms or file uploads, where the number of possible combinations grows exponentially with every additional flag. A state machine makes this complexity visible, testable and traceable in a diagram, instead of hiding it behind dozens of scattered if conditions.
State Machines With XState — Key Takeaways
Core Model
States, events and transitions replace scattered boolean flags and exclude impossible states.
Guards & Actions
Guards check conditions before transitions, actions run side effects during the transition.
Hierarchy & Context
Compound and parallel states structure complex flows, context carries extended data.
Framework Integration
@xstate/react and @xstate/vue bind the same actor core to different frameworks.