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.
XState is a library developed by David Khourshid that provides us with the ability to create and run state machines in JavaScript/TypeScript, along with a thorough and easily navigated set of documentation. It also provides us with the XState visualizer, which allows both technical and non-technical people to see how we can move through the finite set of states for a given system, thus providing “a common language for designers and developers.”
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.
XState Machine
instances take two object arguments, configuration
and options
. The 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.
const xStateMachine = Machine<Context, Schema, Transitions>(
xStateConfig,
xStateOptions
);
The three type arguments that we use to compose our machine are schema
, transitions
, and 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
schema
. -
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
context
data store ahead of time.
Let’s take some time to look at the initial configuration of a state machine:
const xStateConfig: MachineConfig<Context, Schema, Transitions> = {
id: "Email Application",
initial: "HOME_PAGE",
context: {},
states: {}
};
- 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
context
data 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 asundefined
.
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.
States
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 OPEN_EMAILS
.
interface Schema {
states: {
HOME_PAGE: {};
INBOX: {};
};
};
type Transitions = { type: "OPEN_EMAILS" };
const xStateConfig: MachineConfig<Context, Schema, Transitions> = {
id: "Email Application",
initial: "HOME_PAGE",
context: initialContext,
states: {
HOME_PAGE: {
id: "HOME_PAGE",
on: { OPEN_EMAILS: "INBOX" },
},
INBOX: {
id: "INBOX",
}
}
};
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 on
property.
Options
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 LOADING_EMAILS
state.
type Context = {
emails?: [];
};
const initialContext: Context = {
emails: undefined,
};
interface Schema {
states: {
HOME_PAGE: {};
LOADING_EMAILS: {};
INBOX: {};
APPLICATION_ERROR: {};
};
};
type Transitions = { type: "OPEN_EMAILS"}
const xStateConfig: MachineConfig<Context, Schema, Transitions> = {
id: "Email Application",
initial: "HOME_PAGE",
context: initialContext,
states: {
HOME_PAGE: {
on: { OPEN_EMAILS: "LOADING_EMAILS" },
},
LOADING_EMAILS: {
invoke: {
id: "LOADING_EMAILS",
src: (context, event) => 'fetchEmails',
onDone: {
actions: 'setEmails',
target: "INBOX",
},
onError: {
target: "APPLICATION_ERROR",
},
},
},
INBOX: {
id: "INBOX",
},
APPLICATION_ERROR: {
after: {
5000: `HOME_PAGE`,
},
},
},
};
const xStateOptions: Partial<MachineOptions<Context, any>> = {
services: {
fetchEmails: async () => {
return new Promise<void>((resolve, reject) =>{
resolve();
// reject();
})
},
},
actions: {
setEmails: assign({ emails: (context, event) => event.data }),
}
}
const xStateMachine = Machine<Context, Schema, Transitions>(
xStateConfig,
xStateOptions
);
The four keys in the configuration for invoke
are id
, src
, onDone
, and 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 fetchEmails
are context
and event
, giving it access to all the context
and 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.
2. Guards
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.
const xStateConfig: MachineConfig<Context, Schema, Transitions> = {
id: "Email Application",
initial: "HOME_PAGE",
context: initialContext,
states: {
HOME_PAGE: {
on: { OPEN_EMAILS: "LOADING_EMAILS" },
},
LOADING_EMAILS: {
invoke: {
id: "LOADING_EMAILS",
src: 'fetchEmails',
onDone: {
actions: 'setEmails',
target: "ENTERING_APPLICATION",
},
onError: {
target: "APPLICATION_ERROR",
},
},
},
ENTERING_APPLICATION: {
id: "ENTERING_APPLICATION",
always:[
{
target: "DRAFT_EMAIL",
cond: 'isDraftingEmail',
},
{ target: "INBOX" }
]
},
INBOX: {
id: "INBOX",
},
DRAFT_EMAIL: {
id: "DRAFT_EMAIL",
},
APPLICATION_ERROR: {
after: {
5000: `HOME_PAGE`,
},
},
},
}
const xStateOptions: Partial<MachineOptions<Context, any>> = {
services: {
fetchEmails: async () => {
return new Promise<void>((resolve, reject) =>{
resolve();
// reject();
})
},
},
actions: {
setEmails: assign({ emails: (context, event) => event.data }),
},
guards: {
isDraftingEmail: () => {
return true;
// return false;
}
}
}
const xStateMachine = Machine<Context, Schema, Transitions>(
xStateConfig,
xStateOptions
);
XState Visualizer
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 fetchEmails
and isDraftingEmails
functions in the Sandbox.
XState Email Application Visualizer
Conclusion
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!
Top comments (0)