Building Multi-Step Forms with XState
Managing complex multi-step forms often leads to brittle codebases cluttered with boolean flags, scattered validation logic, and untracked edge cases. Web development teams increasingly turn to finite state machines and statecharts via XState to solve this problem. This article explores how teams structure, validate, and execute complex form workflows by separating dynamic UI rendering from core business and navigation logic using XState.
The Challenge of Multi-Step Forms
Traditional multi-step forms rely heavily on local component state to
handle progression, such as tracking currentStep,
isSubmitting, hasErrors, or
canGoBack. As business requirements expand—introducing
conditional branching, dynamic validations, asynchronous data fetching,
and persistence—these independent variables create millions of
theoretical state combinations. This state explosion inevitably results
in "impossible states," such as a form appearing to submit while
concurrently moving back to a previous step.
Modeling Forms as Statecharts
XState solves this problem by enforcing a formal mathematical model:
a finite state machine. At any given moment, the form can exist in
exactly one top-level state (e.g., personalInfo,
billingDetails, review, or
submitting).
Teams typically map their forms into three primary elements:
- States: The distinct steps of the form, including
non-input states like
validating,submitting,success, andfailure. - Events: Declarative actions dispatched by the user
or system, such as
NEXT,PREVIOUS,INPUT_CHANGED, orSUBMIT. - Context: The extended state holding the quantitative data, such as field values, error messages, and submission tokens.
Step-by-Step Implementation Strategy
1. Defining the Machine Schema and Context
Teams start by defining the TypeScript types for the form data (context) and the events that can mutate that data. The machine context stores the aggregate form values across all steps, keeping the data layer centralized rather than distributed across multiple view components.
interface FormContext {
formData: {
name?: string;
email?: string;
plan?: string;
};
errors: Record<string, string>;
}
type FormEvent =
| { type: 'UPDATE_FIELD'; field: string; value: any }
| { type: 'NEXT' }
| { type: 'PREVIOUS' }
| { type: 'SUBMIT' };2. Implementing Guards for Step Validation
Transitioning between steps must often be gated behind validation
rules. In XState, teams use guards (conditional
transitions). When a user dispatches a NEXT event, the
machine checks whether the current step's inputs are valid.
If the guard passes, the machine transitions to the next step. If it fails, the machine can remain in the current state while executing an action that populates error messages in the context. This completely prevents the UI from transitioning to subsequent steps if prerequisite data is missing.
3. Managing Conditional Branching
Many enterprise multi-step forms feature dynamic paths (for example, showing an enterprise questionnaire only if a user selects an enterprise plan). Teams model this directly in the transition definitions:
review: {
on: {
NEXT: [
{ target: 'enterpriseDetails', cond: 'isEnterprisePlan' },
{ target: 'paymentDetails' }
]
}
}This makes conditional logic explicit and visual, eliminating
convoluted if/else checks within the UI components.
4. Handling Async Operations and Side Effects
Forms often require asynchronous checks between steps, such as verifying an email address or validating credit card data against an external API.
Teams leverage XState's invoked services. When entering a validating or submitting state, the machine invokes a Promise or an async function:
onDone: Transitions to the next step and writes any response data to context.onError: Transitions to an error state or returns to the active step with error details.
This guarantees that submission spinners, network retries, and failure states are fully controlled by the machine rather than manually tracked loading booleans.
5. Decoupling Logic from the UI
With XState, UI components become thin display layers. Whether using
React, Vue, or Svelte, teams consume the state machine through
framework-specific hooks (such as useMachine).
The UI simply reads the current state to determine which step to render, reads the context for input values and validation errors, and dispatches events to the machine. The component does not know how to validate, where to submit, or what the next step is; it only reports user intentions back to the state machine.
Key Benefits for Development Teams
- Elimination of Impossible States: UI elements cannot trigger submissions while navigating or validate when already submitting.
- Visual Documentation: XState machines can be inspected and visualized using the Stately visualizer, enabling designers, product managers, and developers to review the workflow together.
- Isolated Testing: Because state machines are independent of the DOM, teams can test complex multi-step workflows, conditional branching, and API edge cases via standard unit tests without mounting UI components.
- Resilience and Persistence: The current state and context can easily be serialized to local storage or a backend session, allowing users to safely resume partially completed forms after a page refresh.