How to Think in Statecharts with XState: A React Guide
When building complex user interfaces, managing state can be a challenging task. As the number of possible states and transitions increases, the complexity of our code can spiral out of control. One solution to this problem is to use statecharts, a powerful way of modeling the state of a system and how it changes over time. In this post, we’ll explore how to think in statecharts using XState, a state management library for JavaScript that embraces statecharts, and how to integrate it with React.
What are Statecharts?
Statecharts are an extension of finite state machines (FSMs) that add hierarchical and orthogonal states, as well as guards, actions, and other concepts to create a more powerful and expressive model for complex systems. They allow you to visualize the possible states of your system and the transitions between them, which can help simplify your code and make it more maintainable.
Getting Started with XState
XState is a JavaScript library for creating, interpreting, and executing statecharts. It allows you to model your system as a statechart and manage its state transitions in a clear and declarative way. To get started with XState, you’ll first need to install it as a dependency in your project:
npm install xstate
Defining a Statechart with XState
To define a statechart, you’ll use the createMachine
function provided by XState. This function takes an object describing the states and transitions of your system. Let's consider an example where we have a simple authentication flow with three states: "idle", "loading", and "authenticated". Here's how you would define a statechart for this system:
import { createMachine } from "xstate";
const authMachine = createMachine({
id: "auth",
initial: "idle",
states: {
idle: {
on: {
LOGIN: "loading",
},
},
loading: {
on: {
SUCCESS: "authenticated",
FAILURE: "idle",
},
},
authenticated: {
on: {
LOGOUT: "idle",
},
},
},
});
Integrating XState with React
To use your statechart with React, you’ll need to install the @xstate/react
package:
npm install @xstate/react
The @xstate/react
package provides a useMachine
hook that allows you to easily integrate your statechart into a React component. Here's an example of how to use the useMachine
hook with the authMachine
we defined earlier:
import React from "react";
import { useMachine } from "@xstate/react";
import { authMachine } from "./authMachine";
const LoginComponent = () => {
const [state, send] = useMachine(authMachine);
const handleLogin = () => {
send("LOGIN");
};
const handleLogout = () => {
send("LOGOUT");
};
return (
<div>
{state.matches("idle") && <button onClick={handleLogin}>Login</button>}
{state.matches("loading") && <p>Loading...</p>}
{state.matches("authenticated") && (
<>
<p>Welcome!</p>
<button onClick={handleLogout}>Logout</button>
</>
)}
</div>
);
};
In this example, we use the useMachine
hook to get the current state of the machine and a send
function that allows us to send events to the machine. We can then use the state.matches
function to conditionally render different parts of our UI based on the current state.
Using Hierarchical and Parallel States
One of the key advantages of statecharts is their ability to model hierarchical and parallel states. Let’s extend our authentication example to include user roles with two hierarchical states: “admin” and “user”. We’ll also add a “failure” state to represent a failed login attempt, with a parallel state that manages a retry counter:
import { createMachine } from "xstate";
const authMachine = createMachine({
id: "auth",
initial: "idle",
states: {
idle: {
on: {
LOGIN: "loading",
},
},
loading: {
on: {
SUCCESS: [
{ target: "authenticated.admin", cond: "isAdmin" },
{ target: "authenticated.user", cond: "isUser" },
],
FAILURE: "failure",
},
},
authenticated: {
type: "parallel",
states: {
admin: {},
user: {},
},
on: {
LOGOUT: "idle",
},
},
failure: {
on: {
RETRY: "loading",
},
initial: "retryCounter",
states: {
retryCounter: {
on: {
"": [
{ target: "maxRetriesReached", cond: "checkIfMaxRetriesReached" },
{ target: "retryAllowed" },
],
},
},
retryAllowed: {},
maxRetriesReached: {},
},
},
},
});
In this example, we use the type: "parallel"
configuration to create parallel states for "admin" and "user" under the "authenticated" state. We also added a "failure" state with nested states to manage the retry counter logic.
Let’s extend the parallel state example by adding more functionality. We’ll model a system where users can toggle between dark and light themes, and receive notifications. The user can be in the “authenticated” state with either an “admin” or “user” role, while simultaneously having the “theme” and “notifications” states. The updated statechart will look like this:
import { createMachine } from "xstate";
const authMachine = createMachine({
id: "auth",
initial: "idle",
states: {
idle: {
on: {
LOGIN: "loading",
},
},
loading: {
on: {
SUCCESS: [
{ target: "authenticated.admin", cond: "isAdmin" },
{ target: "authenticated.user", cond: "isUser" },
],
FAILURE: "failure",
},
},
authenticated: {
type: "parallel",
states: {
role: {
initial: "user",
states: {
admin: {},
user: {},
},
},
theme: {
initial: "light",
states: {
light: {
on: { TOGGLE_THEME: "dark" },
},
dark: {
on: { TOGGLE_THEME: "light" },
},
},
},
notifications: {
initial: "enabled",
states: {
enabled: {
on: { TOGGLE_NOTIFICATIONS: "disabled" },
},
disabled: {
on: { TOGGLE_NOTIFICATIONS: "enabled" },
},
},
},
},
on: {
LOGOUT: "idle",
},
},
failure: {
on: {
RETRY: "loading",
},
initial: "retryCounter",
states: {
retryCounter: {
on: {
"": [
{ target: "maxRetriesReached", cond: "maxCheckIfMaxRetriesReached" },
{ target: "retryAllowed" },
],
},
},
retryAllowed: {},
maxRetriesReached: {},
},
},
},
});
In this example, we’ve added two new parallel states under the “authenticated” state: “theme” and “notifications”. The “theme” state has two sub-states, “light” and “dark”, and the “notifications” state has “enabled” and “disabled” sub-states. Both states can be toggled independently using the TOGGLE_THEME
and TOGGLE_NOTIFICATIONS
events.
To use this updated statechart with React, you can update the LoginComponent
to handle the new events and render the current theme and notifications state:
import React from "react";
import { useMachine } from "@xstate/react";
import { authMachine } from "./authMachine";
const LoginComponent = () => {
const [state, send] = useMachine(authMachine);
const handleLogin = () => {
send("LOGIN");
};
const handleLogout = () => {
send("LOGOUT");
};
const toggleTheme = () => {
send("TOGGLE_THEME");
};
const toggleNotifications = () => {
send("TOGGLE_NOTIFICATIONS");
};
return (
<div>
{state.matches("idle") && <button onClick={handleLogin}>Login</button>}
{state.matches("loading") && <p>Loading...</p>}
{state.matches("authenticated") && (
<>
<p>Welcome!</p>
<button onClick={handleLogout}>Logout</button>
<p>Theme: {state.matches("authenticated.theme.light") ? "Light" : "Dark"}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
<p>
Notifications:{" "}
{state.matches("authenticated.notifications.enabled") ? "Enabled" : "Disabled"}
</p>
<button onClick={toggleNotifications}>Toggle Notifications</button>
</>
)}
</div>
);
};
In this updated component, we’ve added two new buttons to toggle the theme and notifications, and we display the current theme and notifications state using the state.matches
function. With this extended parallel state example, we’ve shown how statecharts can model multiple independent states that coexist simultaneously, allowing for more complex and expressive state management.
Conclusion
Statecharts provide a powerful and expressive way to model complex state management systems. XState makes it easy to create statecharts in JavaScript and integrate them with React using the @xstate/react
package. By embracing statecharts and XState, you can simplify your code, make it more maintainable, and improve your ability to reason about the state of your application.