State Machines With React
As web applications sprawl and user interactions grow in complexity, keeping track of global state can become daunting to maintain. This challenge is often met with the use of libraries like Redux and MobX, but they come with their own labyrinth of opinions and learning arcs.
A simpler alternative can be achieved with the mathematical simplicity and precision of a State Machine. This article delves into this issue comparing popular solutions, followed by a quick explaination of the theory behind the concept and provides an example implementation in React.
Shortfalls of State Management in React⌗
When it comes to state management in React applications, developers often start with built-in hooks like useState
and useReducer
, which are simple and straightforward ways to manage state at a component level. The useState
hook is ok for handling simple states such as toggling a button or displaying a modal, as straightforward as:
const [isOpen, setIsOpen] = useState(false);
For slightly more complex state logic involving multiple actions, useReducer
comes into play. It allows you to manage state transitions in a more structured way, inspired by the reducer function in Redux:
const [state, dispatch] = useReducer(reducer, initialState);
When state is shared among multiple components or needs to be persisted across routes, global state managers are usually introduced to a project, providing a single source of truth for the states and allowing you to manage it in a predictable way using actions and reducers.
Redux provides a robust but complex solution for global state management, requiring actions, reducers, and additional libraries for asynchronous tasks. MobX simplifies this with observables and actions but can become hard to debug for complex states. State machines offer a streamlined way to manage state transitions, making the system easier to test and debug, even as complexity grows.
A primer on State Machines⌗
In the realm of computer science, a state machine is a mathematical model of computation that provides an abstract representation of a system that can be in one of a finite number of states. It processes a sequence of events or inputs and transitions from one state to another based on a set of rules or conditions. Each transition can optionally produce an output or perform an action, but the primary focus is the controlled movement between states.
A basic state machine consists of:
- States: The distinct conditions that the machine can exist in.
- Transitions: Rules that dictate the switch from one state to another.
- Initial State: The state where the machine starts.
- Final State(s): States where the machine can terminate its operation (not always applicable).
The underlying mathematics of state machines is anchored in set theory. In deterministic finite-state machines, the transition function is often represented as δ : S × Σ → S
. In this expression, S is a finite set of states, and Σ is the input alphabet, which represent possible actions and events that result in a change of state. The function δ maps each combination of a state in S and an input symbol in Σ to a single new state in S.
Simple enough, right?
Such formalism enables the application of rigorous analysis methods, like model checking, to ensure the intended behavior of the system is upheld. This simple concept governs from traffic lights to the actions of NPCs in videogames.
Practical example: Multi-Step Form Component⌗
Imagine you’re developing a travel booking application where users go through multiple steps to complete their reservation. This booking form example has these interconnected sections: Personal Information, Flight Selection, Payment Details, Review, and Confirmation.
Each section has unique data fields, validation logic, and potential side-effects like API calls. Additionally, the user experience becomes complex due to the ability to navigate through these sections both forwards and backwards, save progress, or even exit and return later.
To tackle this, you define specific states for each step, like personalInfo
or flightSelection
, and allow certain transitions between them. User actions like NEXT
, PREV
, and SUBMIT
serve as triggers for these state transitions, and state machines can handle side-effects efficiently, such as validating data before proceeding to the next section.
Create a Machine with XState⌗
XState is a library that provides a comprehensive set of methods to define, interpret, and execute finite state machines and statecharts. It also has its own state visualizer called viz.
Let’s create a machine with an ID, an initial state, and possible states like personalInfo
, flightSelection
, and so on. Each state listens for specific events like NEXT
or PREV
, which when triggered, move the machine to a new target state. Side-effects, like data validation or API calls, can be included as actions during these transitions.
import { createMachine, assign } from 'xstate';
export const multiStepFormMachine = createMachine({
id: 'multiStepForm',
initial: 'personalInfo',
context: {
// Store additional data here if needed
},
states: {
personalInfo: {
on: {
NEXT: {
target: 'flightSelection',
actions: [
// Add any side-effects like data validation here
]
},
},
},
flightSelection: {
on: {
NEXT: {
target: 'paymentDetails',
actions: [
// Add side-effects like fetching available flights
]
},
PREV: 'personalInfo'
},
},
paymentDetails: {
on: {
NEXT: 'review',
PREV: 'flightSelection'
},
},
review: {
on: {
NEXT: 'confirmed',
PREV: 'paymentDetails',
SUBMIT: 'confirmed'
},
},
confirmed: {
type: 'final'
}
}
});
Integrate with React⌗
You can then import the previous file (named multiStepFormMachine.js in this example) and use this machine in your React component to manage its state:
import { useMachine } from '@xstate/react';
import { multiStepFormMachine } from './multiStepFormMachine';
function MultiStepForm() {
const [state, send] = useMachine(multiStepFormMachine);
return (
<div>
{state.matches('personalInfo') && <div>Personal Information Form</div>}
{state.matches('flightSelection') && <div>Select Your Flight</div>}
{state.matches('paymentDetails') && <div>Payment Details</div>}
{state.matches('review') && <div>Review Your Information</div>}
{state.matches('confirmed') && <div>Confirmation</div>}
{/* Conditionally render buttons based on the current state */}
{!state.matches('confirmed') && (
<>
<button onClick={() => send('PREV')}>Previous</button>
<button onClick={() => send('NEXT')}>Next</button>
</>
)}
</div>
);
}
The hook useMachine
from XState is used to manage the component’s state, returning the current state
of the machine and a function to send
events to the machine. The component uses conditionally rendered divs to display the form based on the current state checked via state.matches
. Buttons with onClick
handlers are provided to navigate between form steps, sending PREV
or NEXT
events to transition to the appropriate states. They are are also conditionally rendered, appearing only when the state is not confirmed.
Global state on React’s context⌗
We can initiate and run our state machine with XState’s useInterpret
hook, yielding a service that acts as a reference and avoids superfluous re-renders. This service is then incorporated within a React Context Provider, granting nested components access to its values.
import React, { createContext } from 'react';
import { useInterpret } from '@xstate/react';
import { multiStepFormMachine } from './multiStepFormMachine';
export const FormStateContext = createContext({});
export const FormStateProvider = ({ children }) => {
const formService = useInterpret(multiStepFormMachine);
return (
<FormStateContext.Provider value={{ formService }}>
{children}
</FormStateContext.Provider>
);
};
Now child components have access to the state machine’s service through React’s useContext
. To listen for changes in the state on them, XState provides a couple of options: The useActor
hook refreshes the state whenever the service undergoes any changes. Since we’re aiming to enhance performance, useSelector
is the go-to. It allows to selectively tap into specific segments of the state, ensuring components are only re-rendered when truly necessary.
import React, { useContext } from 'react';
import { FormStateContext } from './FormStateContext';
import { useSelector } from '@xstate/react';
const flightSelector = (state) => {
return state.matches('flightSelection');
};
export const FlightSummary = () => {
const { formService } = useContext(FormStateContext);
const isFlightSelected = useSelector(formService, flightSelector);
return isFlightSelected ? <div>Summary of your flight choices</div> : null;
};