Model complex UI state with finite state machines. Covers XState fundamentals, statechart theory, managing multi-step forms, modal flows, and the patterns that eliminate impossible states by making valid transitions explicit.
Frontend applications often transition through complex sequences of user interactions, data loading, and error states. When a component or view must manage multiple, interdependent states — such as loading, success, error, editing, validation, and confirmation — a state machine becomes the most reliable way to model and implement that behavior. This pattern is essential when the logic is too intricate for simple boolean flags or if-else chains, and when transitions between states must be predictable, testable, and reusable across components.
State machines are most valuable when a UI component or workflow must respond to a sequence of events that trigger distinct behaviors. The pattern shines when a component needs to:
The core structure is a set of states, events, and transitions between states, with optional entry and exit actions.
A state machine is defined as a set of named states, each with a set of possible transitions triggered by events. Each transition may include side effects such as updating a model, fetching data, or triggering a UI animation.
In a typical frontend implementation, a state machine is represented as a plain object with states and transitions keys. The machine starts in an initial state and progresses via event dispatches.
{
"initial": "idle",
"states": {
"idle": {
"on": {
"LOAD_DATA": "loading",
"START_EDIT": "editing"
}
},
"loading": {
"on": {
"DATA_SUCCESS": "loaded",
"DATA_ERROR": "error"
},
"entry": ["startSpinner"],
"exit": ["stopSpinner"]
},
"loaded": {
"on": {
"REFRESH": "loading",
"EDIT": "editing"
}
},
"editing": {
"on": {
"SAVE": "saving",
"CANCEL": "idle"
}
},
"saving": {
"on": {
"SAVE_SUCCESS": "saved",
"SAVE_ERROR": "error"
},
"entry": ["showSaveFeedback"]
},
"saved": {
"on": {
"EDIT": "editing"
},
"exit": ["showSuccessToast"]
},
"error": {
"on": {
"RETRY": "loading"
},
"entry": ["showErrorMessage"]
}
}
}
This configuration defines a machine where a component can transition between idle, loading, loaded, editing, saving, saved, and error states. Each state can define actions to run when entering or exiting, and each event triggers a transition.
Transitions are initiated by dispatching events. In JavaScript, this is typically done via a send method on the machine instance.
const machine = createMachine(stateConfig);
const service = interpret(machine);
service.start(); // starts in 'idle' state
service.send('LOAD_DATA'); // transitions to 'loading'
// After API response:
service.send('DATA_SUCCESS'); // transitions to 'loaded'
// User clicks edit:
service.send('EDIT'); // goes to 'editing'
The send function is synchronous and supports payload data, which can be used in actions.
service.send({
type: 'SAVE',
data: { title: 'New post', content: 'Hello world' }
});
The machine receives the event object and applies it to the current state. If the event is not defined in the current state, the machine looks up the state hierarchy (if any) or falls back to the top-level on definition.
Actions are functions that run during state transitions. They can be defined inline or referenced by name. Common actions include:
startSpinner: () => document.getElementById('spinner').style.display = 'block'showSaveFeedback: () => notify('Saving…', 'info')showErrorMessage: (context, event) => alert(event.error.message)Actions can access and modify the machine’s context — a plain object that stores data relevant to the state.
const machine = createMachine({
initial: 'idle',
context: {
userId: null,
posts: [],
error: null
},
states: {
loading: {
entry: [
'startSpinner',
'fetchPosts'
],
on: {
'DATA_SUCCESS': {
target: 'loaded',
actions: ['setPosts', 'updateLastFetched']
}
}
},
loaded: {
on: {
'REFRESH': 'loading'
},
exit: ['clearSelection']
}
}
});
The context is updated during transitions and available in all actions. It acts as a shared state between the machine and its components.
When a component has multiple, independent state hierarchies, the machine supports nested and parallel states.
{
"initial": "active",
"states": {
"active": {
"type": "parallel",
"states": {
"navigation": {
"initial": "menu-open",
"states": {
"menu-open": {
"on": { "CLOSE_MENU": "menu-closed" }
},
"menu-closed": {
"on": { "OPEN_MENU": "menu-open" }
}
}
},
"form": {
"initial": "untouched",
"states": {
"untouched": {
"on": { "INPUT": "touched" }
},
"touched": {
"on": { "BLUR": "validating" }
},
"validating": {
"on": {
"VALID": "valid",
"INVALID": "invalid"
}
},
"valid": {
"on": { "RESET": "untouched" }
},
"invalid": {
"on": { "FIX": "touched" }
}
}
}
}
}
}
}
This structure allows independent state management: navigation and form state can change simultaneously. The machine’s current state is a composite of both, such as { navigation: 'menu-open', form: 'validating' }.
A common failure mode is missing event definitions. If an event is dispatched but not declared in the current state, the machine silently ignores it. This leads to unresponsive UIs when events are expected but not processed.
Another subtle issue: actions are not guaranteed to run in order. If multiple actions are defined in a on clause, they execute in the order they are listed, but only if the machine’s actions array is strictly ordered.
"on": {
"SAVE": {
"target": "saving",
"actions": [
"showSaveFeedback",
"logEvent",
"validateForm"
]
}
}
If validateForm throws an error, the machine may not transition to saving unless the action explicitly includes a transition effect.
To ensure robustness, always use assign actions to update context, and define entry and exit actions for cleanup tasks.
State machines are most effective when encapsulated as reusable components. A StateMachine component can accept a machine configuration and dispatch events via props.
function FormComponent({ machineConfig }) {
const service = useMemo(() => interpret(createMachine(machineConfig)), [machineConfig]);
const [state, send] = useMachine(service);
useEffect(() => {
return () => service.stop();
}, [service]);
return (
<div className={`state-${state.value}`}>
{state.value === 'loading' && <Spinner />}
{state.value === 'error' && <ErrorMessage error={state.context.error} />}
<button onClick={() => send('SAVE')}>Save</button>
<button onClick={() => send('CANCEL')}>Cancel</button>
</div>
);
}
This component can be reused across the application, with different machines for different forms.
When a state machine fails in production, the most common cause is an unhandled event. The machine logs all transitions, but only if explicitly instrumented.
To enable debugging, add a logger configuration to the interpreter:
const service = interpret(machine, {
logger: (event, state) => {
console.log('State transition:', event, 'from', state.value, 'to', state.nextStates);
}
});
This logs every event and transition, helping to trace unexpected behavior.
Another common issue: context is not updated after a transition. If a send call includes a payload, and the next state uses assign, the context may not reflect the updated values unless the assign action is explicitly defined.
"on": {
"LOAD_USER": {
"target": "user-loaded",
"actions": [
"assignUser",
"updateUserProfile"
]
}
}
Without assignUser, the context remains unchanged, and component state may be stale.
actions array.assign actions are used.stopSpinner or clearSelection are forgotten, leading to memory leaks or inconsistent UIs.Use a state machine when:
The pattern is not overkill for simple forms, but becomes essential when the component must handle complex workflows, such as multi-step wizards, data grids with inline editing, or real-time collaboration interfaces.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy