How to Think in Statecharts with XState: A React Guide

Pavlo Lompas
5 min readApr 19, 2023

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.

--

--