Using XState for UI flows | Paulius Friedt
For our client Pio, we designed and built a web application for warehouse workers to interact with a warehouse grid. Pio is a warehouse automation provider that creates grid systems to store items. Warehouse workers use the application to interact with the grid, main interaction flows being storing of items in the grid and retrieving items from the grid when fulfilling an order.
In this post we will use item storing flow as an example. In the following flow chart you can see a simplified version of the flow that includes manual and grid steps.
Storing flow
The application is built using React and Next.js as the main framework. When using Next.js Page Router, separate flow steps would be implemented as individual pages with unique URLs. However we wanted to fully control a users' journey within a flow to prevent navigation between separate steps using built-in browser navigation. We started looking at alternative ways to implement warehouse grid flows. Our initial explorations lead us to two options - RxJS and XState based implementations. Initially we chose RxJS as we had team members with relevant experience whereas XState was new to the whole team.
Initial RxJS based implementation
RxJS is a library for composing asynchronous and event-based flows by using observable sequences. It allows to build an observable stream that reacts and manages sequences of external events. We implemented single flow as a single observable stream that managed user input and progressed to the next step only when all required input was received.
However this implementation had a couple of drawbacks:
- Complex stream definition made it hard to isolate single state logic and understand what events are allowed on a specific state, where the user can move from a single state, etc.
- Some flow logic was implemented in the views separately from the stream.
- Introducing flow branching in the middle of a flow based on user choices made flow stream definition even more complex.
Refactoring using XState
After our initial implementation became unapproachable for new team members and not flexible for future changes we started looking at alternatives, mainly XState.
XState is a JavaScript and TypeScript library for creating, interpreting, and executing finite state machines and statecharts, as well as managing invocations of those machines as actors. State machines are a way to model any process in defining states, events and transition between them. Statecharts is an extension to state machines, that allow to model more complex logic, including hierarchy, concurrency and communication.
By using XState it was easy to model each flow as an individual state machine, where states would map to steps in the flow and events would be sent in UI event handlers to either update machine state or trigger a transition. State machine context was used as global state removing the need to introduce a third party state handling library.
Single flow machine
Following state machine is a base model for our storing flow example.
const machine = createMachine(
{
id: 'storingFlow',
initial: 'choosingLayout',
context: {
binLayoutId: null,
productId: null,
quantity: 0,
},
states: {
choosingLayout: {
entry: 'resetContext',
on: {
SELECT_LAYOUT: {
actions: 'assignSelectedLayout',
},
CONFIRM_BIN_INSERTION: {
target: 'choosingProduct',
cond: 'isLayoutSelected',
},
},
},
choosingProduct: {
on: {
SELECT_PRODUCT: {
target: 'confirmingBinInsertion',
actions: 'assignSelectedProduct',
},
BACK: {
target: 'choosingLayout',
},
},
},
confirmingBinInsertion: {
on: {
INSERT_PRODUCT: {
target: 'storingBin',
actions: 'assignProductQuantity',
},
BACK: {
target: 'choosingProduct',
},
},
},
storingBin: {
// Handle API call to the grid controller
},
complete: {
on: {
RESTART_FLOW: 'choosingLayout',
},
},
},
},
{
actions: {
// Actual action implementations
},
},
);
Note, this and other examples refer to action implementations by string names.
Actual implementations are passed in as a second parameter to the
createMachine()
function call.
@xstate/react package
contains utilities for using XState with React. We use useInterpret()
utility
hook to start interpreting our machine. It returns a reference to a running
service
which contains a member send
function for sending events to this
specific instance. Using useActor()
and useSelector()
utility hooks we can
subscribe to state changes or select specific values from context within a React
component.
// flowContext.ts
const FlowMachineProvider = ({children}) => {
/* Start state machine interpreter */
const service = useInterpret(machine);
return (
<FlowContext.Provider value={service}>{children}</FlowContext.Provider>
);
};
const useFlowMachine = () => {
const service = useContext(FlowContext);
return service;
};
// flowView.ts
const FlowView = () => {
const service = useFlowMachine();
/* select value from context and subscribe to its updates */
const binLayoutId = useSelector(service, state => state.context.binLayoutId);
/* get full machine state and subscribe to all updates */
const [state] = useActor(service);
/* send events to state machine */
service.send({type: 'SELECT_LAYOUT', layoutId: 1});
};
To implement a flow as a single route in Next.js we subscribe to machine state updates and render different view components based on the current state value. As each individual view is a React component, it can also use XState utility hooks to get access to machine context and state updates.
const Flow = () => {
/* Get running machine instance */
const service = useFlowMachine();
/* Subscribe to state changes, forces component to rerender */
const [state] = useActor(service);
/* Render different views based on current state */
return (
<FlowPage>
{state.matches('choosingLayout') && <ChoosingLayoutView />}
{state.matches('choosingProduct') && <ChoosingProductView />}
{state.matches('confirmingBinInsertion') && (
<ConfirmingBinInsertionView />
)}
{state.matches('storingBin') && <StoringBinView />}
{state.matches('complete') && <CompleteView />}
</FlowPage>
);
};
Multiple flow support with actor model
In the application we had to support multiple different flows. To achieve this
we used actor model by
introducing a hierarchy of machines. Main machine controls which flow is active
and exposes events to start and stop a specific flow. Upon receiving
{ type: 'FLOW.START', flow: 'flowName' }
event from the UI, parent machine
will find a specific flow machine definition based on the event parameter and
use it to spawn a new child actor. Actor reference returned from the spanw()
function call can be stored in context and used in further communication between
parent and child actors.
const portMachine = createMachine(
{
id: 'port',
initial: 'flow.idle',
states: {
flow: {
states: {
idle: {
on: {
'FLOW.START': {
actions: 'spawnFlowActor',
target: 'active',
},
},
},
active: {
on: {
'FLOW.END': {
target: 'idle',
actions: 'stopFlowActor',
},
},
},
},
},
},
},
{
actions: {
spawnFlowActor: assign({
actorRef: (_, event) => {
const machine = getFlowMachine(event.flow);
return spawn(machine, {
name: 'flowActor',
});
},
}),
stopFlowActor: () => stop('flowActor'),
sendEventToFlowActor: send(
context => ({
type: 'EVENT_TYPE',
}),
{to: context => context.actorRef},
),
},
},
);
Next.js to state machine integration
We also wanted the main machine to interact with Next.js router to automatically
navigate to a specific flow page whenever FLOW.START
event was sent. This was
achieved by passing in custom action implementation to the interpreter together
with the machine definition.
// ...
const router = useRouter(); // Using Next.JS hook
const portService = useInterpret(portMachine, {
actions: {
navigateToPortIndex: () => {
router.push(portIndexUrl);
},
navigateToPortFlow: (_context, event) => {
router.push(getFlowPageUrl(event.flow));
},
},
});
// ...
In the machine definition we refer to these actions by name as well.
// ...
states: {
idle: {
on: {
'FLOW.START': {
// Refer to `navigateToPortFlow` action
actions: ['spawnFlowActor', 'navigateToPortFlow'],
target: 'active',
},
},
},
active: {
on: {
'FLOW.END': {
// Refer to `navigateToPortIndex` action
actions: ['stopFlowActor', 'navigateToPortIndex'],
target: 'idle',
},
},
},
},
// ...
Conclusion
We achieved several important improvements after migrating to XState based implementation:
- Isolating all flow logic inside a flow specific machine definition.
- Implementing flow pages as single Next.js pages with a collection of View components, whose only responsibility is to display the UI based on global flow context and send events to flow machine after user interactions.
- Achieving the possibility to create complex flow structures with multiple branches as flows can be easily represented as a chart.