XState is a comprehensive library for modeling and managing state logic using state machines and statecharts. It provides a declarative, visual, and testable approach to handling complex workflows, making it suitable for UIs, backends, and real-time systems.
Key Features:
- State Machines: Define discrete states and transitions between them.
- Statecharts: Extend state machines with hierarchical, parallel, and history states for more complex scenarios.
- Actors: Model independent units of behavior, including promise actors, callback actors, state machine actors, and more.
- Context: Store dynamic, mutable data associated with the state machine.
- Events and Transitions: Handle event-driven state changes declaratively. Visualization and Inspection:
- XState Studio: A powerful tool for visualizing, simulating, and testing state machines in real-time. It simplifies the creation and debugging process by providing an interactive interface.
- Inspection: Enables monitoring and debugging of state machines during runtime. It’s a programmatic API or visualization tool that lets you observe state transitions, events, and actions, making debugging workflows much easier. XState Studio is an excellent example of leveraging inspection for clarity.
Key features
Statecharts
Statecharts are an extension of state machines that add capabilities to model complex workflows. Features include:
- Hierarchical States: States can be nested, allowing for encapsulation and reuse (e.g., “Onboarding” might contain “Filling Form” and “Reviewing”).
- Parallel States: Multiple states can run concurrently (e.g., “Uploading File” and “Updating Progress”).
- History States: Allow a state machine to remember the last active substate, useful for restoring previous state contexts
Tip
Transitions can happen locally in a certain state hierarchy or use the global state identifier
Info
History states are useful if you have a parent state A with children A1 and A2, and another state B at the level of A. When you transition from A1 or A2 to B, and from B back to A, the history state allows you to get back to the state where you left, A1 or A2. Shallow history remembers only the immediate child state, while deep history remembers the entire hierarchy of children states
Events and Transitions
Events trigger transitions between states in XState. Each event specifies an action or data, and transitions are the rules that define how the state machine reacts to these events.
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: { CLICK: 'loading' }
},
loading: {}
}
});Here, the CLICK event transitions the state from idle to loading.
Guards
Guards are conditions or predicates that determine whether a transition between states should occur. They are useful for adding logic to state transitions without cluttering the states themselves.
Actions
Actions are side effects that occur during state transitions. These could involve logging, API calls, or updating some external system.
Note
Transition actors focus on computing the next state based on an event, whereas actions are about performing tasks (like logging, API calls, or updates) when a transition happens.
Example:
const machine = createMachine({
initial: 'idle',
states: {
idle: {
on: {
START: {
target: 'loading',
actions: 'logStart'
}
}
},
loading: {}
}
}, {
actions: {
logStart: () => console.log('Transitioning to loading...')
}
});Tags
Tags are identifiers that can be associated with states to simplify queries and enable filtering. Instead of relying on hardcoded state names, tags allow dynamic behavior based on state characteristics.
Use Case:
Tags can help simplify logic where multiple states share common behavior.
const machine = createMachine({
id: 'example',
initial: 'idle',
states: {
idle: { tags: ['waiting'] },
loading: { tags: ['waiting'] },
success: { tags: ['completed'] },
failure: { tags: ['completed'] }
}
});
// Check for a tag
const isWaiting = state.hasTag('waiting');Event Emitters
An event emitter in XState is a mechanism for sending and receiving events between the state machine and external systems. This is particularly useful for integrating with libraries or services that already use an event-driven architecture, decoupling the state machines from the source of events
Example: Reacting to an external event and sending it to the state machine.
const emitter = new EventEmitter();
const service = interpret(machine).start();
// Forward external events to the machine
emitter.on('EXTERNAL_EVENT', (data) => service.send({ type: 'SOME_EVENT', data }));Tip
If you have a simple use case you might not need such a decoupling, and invoke the state machine directly from a Kafka consumer, for example
Context
Context is mutable data associated with the state machine. It stores dynamic information that cannot be represented as states alone. Context can be updated through actions or events and is useful for managing data like form inputs or API responses. dd Example:
const machine = createMachine({
context: { count: 0 },
initial: 'idle',
states: {
idle: {
on: {
INCREMENT: {
actions: assign({ count: (context) => context.count + 1 })
}
}
}
}
});The context is updated dynamically based on events, enabling the state machine to maintain both state and data.
Actors in XState
Actors in XState represent independent units of behavior that can communicate with one another via events. While inspired by the actor model, XState generalizes the concept to encompass various kinds of invoked logic, including state machines, asynchronous tasks, and dynamic callbacks.
- State Machine Actors are full state machines running as independent actors. They maintain their own state and transitions and can interact with the parent machine or other actors.
- Promise Actors encapsulate asynchronous operations (e.g., API calls or database queries). While called “actors,” they are essentially asynchronous functions managed by the state machine. They:
- Automatically resolve or reject, triggering appropriate transitions (
onDoneoronError). - Tie the promise’s lifecycle to the state invoking it, ensuring cleanup when the state exits. - Callback Actors subscribe to streams or external events, receiving a
sendBackfunction to communicate with the parent state machine. They are useful for: - Handling WebSocket connections or real-time updates. - Managing event-driven workflows. They can also define cleanup logic to run when the actor is stopped, such as unsubscribing from a stream. - Transition Actors behave like reducers in Redux. They compute the next state based on the current state and an incoming event, effectively modeling a simple state transition function.
- Observable Actors integrate with observable streams, such as those from RxJS. They can emit values over time, enabling complex workflows based on reactive streams.
Lifecycle Management: invoke vs. spawn
invoke is used to initiate actors tied to the lifecycle of a state. It starts when the state is entered and stops when the state is exited. It is ideal for operations or subscriptions that are state-specific, such as invoking an API call or listening to an external event during a specific state.
spawn creates long-lived actors independent of any state’s lifecycle. It is useful for persistent processes, such as managing background services or reusable logic that must outlast a single state.
Interpretation
Invoking interpret on a state machine turns the static configuration (the machine definition) into a live, running instance capable of processing events dynamically. The interpreted machine becomes a service that can:
- Process events with .send(event).
- Transition between states.
- Emit updates when states change.
Comparison with Redux
Redux focuses on global state management using a centralized store. State transitions are dictated by reducers, which respond to dispatched actions. However, Redux does not inherently handle the concept of state transitions or the complexity of state logic; you need to implement those patterns manually.