When systems and programs are small, state management is usually rather simple, and it’s easy to envision the status of the application and the various ways in which it can change over time. It’s when we scale and our applications become more complex that challenges arise. As systems grow larger, it’s vital to not just have a plan for state management, but a vision for how the entire system functions. This is where state machines come into play and can offer a comprehensive solution to state management by helping us model our application state.
State machines allow us to build structured and robust UIs while forcing us, as developers, to think through each and every state our application could be in. This added insight can enhance communication not only among developers, but also between developers, designers, and product managers as well.
What are statecharts and state machines?
A finite state machine is a mathematical system that can only ever be in one of a finite number of defined states. A traffic light is a simple example. A traffic light only has four states that it could ever be in: one for each of its three lights (red, yellow, and green) being on while the other two lights are off. The fourth is an error state where the traffic light has malfunctioned.
Statecharts are used to map out the various states of a finite system, similar to a basic user flow chart. Once the finite number of states are determined, transitions — the set of events that move us between each state — are defined. The basic combination of states and transitions are what make up the machine. As the application grows, new states and transitions can be added with ease. The process of configuring the state machine forces us to think through each possible application state, thus clarifying the application’s design.
Using TypeScript — Context, Schema, and Transitions
We can also type our XState machine using TypeScript. XState works nicely with TypeScript because XState makes us think through our various application states ahead of time, allowing us to clearly define our types as well.
Machine instances take two object arguments,
configuration object is the overall structure of the states and transitions. The
options object allows us to further customize our machine, and will be explained in depth below.
The three type arguments that we use to compose our machine are
context. They help us describe every possible state, map out how we move from state to state, and define all the data that can be stored as we progress through the machine. All three are fully defined before the machine is initialized:
- Schema is an entire overview of the map of the machine. It defines all of the states that the application could be in at any given moment.
- Transitions are what allow us to move from state to state. They can be triggered in the UI by event handlers. Instead of the event handlers containing stateful logic, they simply send the type of the transition along with any relevant data to the machine, which will then transition to the next state according to the
- Context is a data store that is passed into your state machine. Similar to Redux, context represents all the data potentially needed at any point in the lifecycle of your program as it moves from state to state. This means that while we may not have all the actual data upon initialization, we do need to define the shape and structure of our
contextdata store ahead of time.
Let’s take some time to look at the initial configuration of a state machine:
- ID is a string that refers to this specific machine.
- Initial refers to the initial state of the machine.
- Context is an object that defines the initial state and shape of our
contextdata store, similar to initial state in Redux. Here, we set out all the potential pieces of state data as the keys in this object. We provide initial values where appropriate, and unknown or possibly absent values can be declared here as
Our machine has all the information it needs to initialize, we have mapped out the various states of the machine, and the gears of our machine are moving. Now let’s dive into how to utilize the various tools provided by XState to trigger transitions and handle data.
To illustrate how XState helps us manage application state, we’ll build a simple example state machine for an email application. Let’s think of a basic email application where, from our initial
HOME_PAGE state (or welcome screen), we can transition into an
INBOX state (the screen where we read our emails). We can define our schema with these two states and define a transition called
With our two states and transition defined, it is clear to see how our state machine begins in the
HOME_PAGE state and has its transition defined in the
1. Services + Actions
We now have a state machine with a basic transition, but we haven’t stored any data in our
context. Once a user triggers the
OPEN_EMAILS transition, we will want to invoke a
service to fetch all the emails for the user and use the
assign action to store them into our
context. Both of these are defined in the options object. And we can define emails within our
context as an optional array since upon initialization of the machine we haven’t yet fetched any emails. We will have to add two new states to our schema: a
LOADING_EMAILS pending state and an
APPLICATION_ERROR error state, if this request fails. We can invoke this request to fetch the emails in our new
The four keys in the configuration for
onError, with the
id being an identifier for the invocation. The
src is the function
fetchEmails that returns our promise containing the email data. Upon a successful fetch, we will move into
onDone, where we can use the
assign action to store the email data returned from our fetch in our
context using the
setEmails action. As you can see, the two arguments to
event, giving it access to all the
event values. We also have to let our machine know where to go next by providing a target state, which in this instance is our
INBOX. We have a similar structure for a failed fetch, in which our target is an error state,
APPLICATION_ERROR, that returns to the
HOME_PAGE state after five seconds.
Conditional state changes can be handled by the use of guards, which are defined in the
options object. Guards are functions that, once evaluated, return a boolean. In XState, we can define this guard in our transition with the key cond.
Let’s add another state for drafting an email,
DRAFT_EMAIL. If a user was previously drafting an email when the application successfully fetches email data, the application would take the user back to the
DRAFT_EMAIL page instead of the
INBOX. We’ll implement this conditional logic with an
isDraftingEmail function. If the user was in the process of drafting an email when data was successfully fetched,
isDraftingEmail will return
true and send the machine back to the
DRAFT_EMAIL state; if it returns
false, it will send the user to the
INBOX state. Our guard will be handled in a new state called
ENTERING_APPLICATION that will be responsible for checking this condition. By using the
always key when defining this state, we tell XState to execute this conditional logic immediately upon entry of the state.
One of XState’s best features is the XState visualizer, which takes in our machine configuration and automatically provides an interactive visual representation of our state machine. These visualizations are how “state machines provide a common language for designers and developers.”
A final look at our XState visualizer shows us the map of our entire email application. Use either link below to test out our machine in a new tab! Once the sandbox is loaded in a new tab, it should open a second new tab with the visualizer. If you don’t see the visualizer, disable your pop-up blocker and refresh the sandbox.
In the visualizer, click on the
OPEN_EMAILS transition to run the state machine. To change the outcome of the machine, comment/uncomment the return values in the
isDraftingEmails functions in the Sandbox.
XState provides a high level understanding of our application via its schema and visualizer, while still offering more granular visibility and control of state and data through its configuration. Its usability helps us tame complexity as our application grows, making it an excellent choice for any developer. Thank you so much for reading and keep an eye out for part II: XState and React!