In this article I would like to introduce and motivate the use of State Machines via ➝ Statecharts notation, implemented through ➝ the Xstate library. This is a somewhat developer-centric introduction, but Statecharts is a powerful tool that is of benefit to everyone involved in creation of software: Project Managers, Designers, Clients etc. If you don't code, just skim through the code parts, and hopefully you can still learn something useful :)
Let's dive right into an example:
Somebody did a Design Sprint, and here is the Spec Sheet:
I've implemented this in both "vanilla" React and React + Xstate, we will compare and contrast both.
Below is the React specific code for both implementations of this example, App.tsx. Please scroll around and compare. I've also provided a link to stackblitz projects, so you can get full insight into them. You can safely hold off checking those out after this article, but choose your own adventure :)
Disclaimer: This is a contrived example of course. I encourage you to imagine how this scales in a real world app, with seperate files, components and more features.
import { useEffect, useState } from "react";
import { dbApi } from "./api";
function App() {
const [todos, setTodos] = useState<string[]>([]);
const [loadingTodos, setLoadingTodos] = useState(false);
const [refreshTodos, setRefreshTodos] = useState(true);
const [todo, setTodo] = useState("");
const [creatingNew, setCreatingNew] = useState(false);
const [savingTodo, setSavingTodo] = useState(false);
const [deletingToDo, setDeletingToDo] = useState("");
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (refreshTodos) {
setLoadingTodos(true);
dbApi.getTodos().then((result) => {
setTodos(Array.from(result.data));
clearTimeout(result.timeoutId);
setRefreshTodos(false);
setLoadingTodos(false);
});
}
}, [refreshTodos]);
useEffect(() => {
const timeOutId = setTimeout(() => {
if (errorMessage) {
setErrorMessage("");
if (deletingToDo) {
setDeletingToDo("");
}
}
}, 3000);
return () => {
clearTimeout(timeOutId);
};
}, [errorMessage]);
return (
<div>
{loadingTodos && <p>Loading...</p>}
{errorMessage && (
<div>
<p style={{ color: "red" }}>{errorMessage}</p>
<button
onClick={() => {
setErrorMessage("");
if (deletingToDo) {
setDeletingToDo("");
}
}}
>
Back to list
</button>
</div>
)}
{!creatingNew && todos.length > 0 && !loadingTodos && !errorMessage && (
<>
<button
autoFocus
onClick={() => {
setCreatingNew(true);
}}
>
Create New
</button>
<ul>
{todos.map((todo) => (
<li key={todo}>
<p>
{todo}{" "}
{deletingToDo === todo ? (
"Deleting..."
) : (
<button
onClick={() => {
setDeletingToDo(todo);
dbApi
.deleteTodo(todo)
.then((result) => {
clearTimeout(result.timeoutId);
setRefreshTodos(true);
setDeletingToDo("");
})
.catch((reason) => {
setErrorMessage(reason.message);
});
}}
>
delete
</button>
)}
</p>
</li>
))}
</ul>
</>
)}
{(creatingNew || todos.length === 0) && !loadingTodos && (
<>
<p>Create a new todo:</p>
<form
onSubmit={(e) => {
e.preventDefault();
setSavingTodo(true);
dbApi.addTodo(todo).then((result) => {
setTodo("");
setCreatingNew(false);
clearTimeout(result.timeoutId);
setRefreshTodos(true);
setSavingTodo(false);
});
}}
>
{savingTodo ? (
<p>Saving...</p>
) : (
<input
autoFocus
onChange={(e) => {
setTodo(e.target.value);
}}
></input>
)}
</form>
</>
)}
</div>
);
}
export default App;
{!creatingNew && todos.length > 0 && !loadingTodos && !errorMessage && (
In a larger application where variables/props/data come from dispirate parts of your application, this can be a nightmare to untangle and make sense of.
In event handlers:
onClick={() => {
setDeletingToDo(todo);
dbApi
.deleteTodo(todo)
.then((result) => {
clearTimeout(result.timeoutId);
setRefreshTodos(true);
setDeletingToDo("");
})
.catch((reason) => {
setErrorMessage(reason.message);
});
}}
And useEffects (which are bug prone):
useEffect(() => {
const timeOutId = setTimeout(() => {
if (errorMessage) {
setErrorMessage("");
if (deletingToDo) {
setDeletingToDo("");
}
}
}, 3000);
return () => {
clearTimeout(timeOutId);
};
}, [errorMessage]);
This hurts portability, making it hard to:
import { useMachine } from "@xstate/react";
import { todosMachine } from "./todoAppMachine";
function App() {
const [state, send] = useMachine(todosMachine);
return (
<>
<div>
{(state.matches("Loading Todos") ||
state.matches("Retry loading todos")) && <p>Loading...</p>}
{state.matches("Show Todos") && (
<>
<button
autoFocus
onClick={() => {
send({ type: "Create new todo" });
}}
>
Create New
</button>
<ul>
{state.context.todos.map((todo) => (
<li key={todo}>
<p>
{todo}{" "}
{state.context.todoToDelete === todo ? (
"Deleting..."
) : (
<button
onClick={() =>
send({ type: "Delete todo", value: todo })
}
>
delete
</button>
)}
</p>
</li>
))}
</ul>
</>
)}
{(state.matches("Deleting todo errored") ||
state.matches("Creating new todo.Saving todo errored")) && (
<div>
<p style={{ color: "red" }}>{state.context.errorMessage}</p>
<button
onClick={() => {
send({ type: "Back" });
}}
>
Back
</button>
</div>
)}
{state.matches("Loading todos errored") && (
<div>
<p style={{ color: "red" }}>{state.context.errorMessage}</p>
<p>Experiencing som issues, please come back later.</p>
</div>
)}
{state.matches("Creating new todo") &&
!state.matches("Creating new todo.Saving todo errored") && (
<>
<p>Create a new todo:</p>
{state.matches("Creating new todo.Saving todo") && (
<p>Saving...</p>
)}
{state.matches("Creating new todo.Showing form input") && (
<form
onSubmit={(e) => {
e.preventDefault();
send({ type: "Submit" });
}}
>
<input
autoFocus
onChange={(e) => {
send({
type: "Form input changed",
value: e.target.value,
});
}}
></input>
</form>
)}
</>
)}
</div>
</>
);
}
export default App;
{state.matches("Show Todos") && (
Yeah... No guessing what state our application is in.
onClick={() =>
send({ type: "Delete todo", value: todo })
}
We just send an event and any relevant data to a centralized place that holds and exceutes our logic.
Anyone thinking of Redux and reducers? :) Unfortunately, Redux only tells half of the story. With Statecharts we can paint the full picture:
⇣Statecharts is a visual formalism for complex systems. Meaning it is a formalized notation that is visual and can accurately describe application logic. Xstate is a library that implements Statecharts in Javascript/Typescript. Stately Studio is a tool that let's you author Xstate code in a visual editor. It integrates with VSCode, and features bidirectional editing of code/diagram:
import { createMachine, assign } from "xstate";
import { dbApi } from "./api";
export const todosMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBUD2FWwAQFkCGAxgBYCWAdmAHQAyqeE5UWaGsAxBhZeQG6oDWVFplyFSXWvUbN0mBL1QE8AFxKoyAbQAMAXW07EoAA6YSq9YZAAPRACYAnADZKtrQFYAjB4DMjgBwA7A4etgA0IACeiL62lFq23t4BAfaeAQAsIQC+WeHC2PjE5FSSDGRM+RzqVAqClPmiRRJ0ZRWysPJkfErmmroaHgZIICawZmpkljYIjgmUGfYJjgG+Wn5e4VEISR4uDumOHvYBHm4pfuk5ee2N4iUt0pVgAE7PqM+URgA2KgBm7wBbeo3Qp3GgPcoyVidboqCb6fSWUbjCzDaZuRaUM4eLQ4+zrdwBTaIDyOLSUQLpQmZRzpQLZXIgBqg4qUADKRFQAHcoZg2ABhZ5gFRgLAUHnKWSI4bI3pTElU8lnPyePwJXEePzeYkzTUuOkeKkY+LedKXRnMsSsjnc3mwSgAETAXzAqkhkowVS4tSEIKtXBtPPyjudrukHtQMMUcPUCN0SNMcrRdi05Mc3lsbjcmS0mSOfh12bcFLc3icfnsxzNZquTL9TSogbtIZdbqYEbYLzeH2+f0BwNYt2tnKD7RbYfdsijPXh-XjMsTE3lCBW3koqW8pfSmdxdMcOuSAUo3lOTniAVTiQ8tctDfZI+bJAgLrYTtbooj0uMi9RoGm62LDFHCcVw1UpbVIkQWZ0jiM0tGA-wkkOa8LXrMFBWFNsxTACVZHvblpH+Z4ASwcgjAAV2UNgADFAVIsgKOULBiDwcpIC-EYf0mZMEGOew4lcEJ7DWZC3B1Ww1XXPxpPSVIKxPLUbzQ1kMLhSFxSwCN8K5Qi6LIyi2DZciACMATMDjZSXHjDVNdcqT3Tw3HcUkdUSZxjm8Px3FNLyzkcJTBxZLhVKwjStLZPAeHDWQvRqLoBF9QL-SoELpDCvCIqiycMGnGM+j0edvzGJM-xJU0j3WFVHEcJzbG3CCtgAWjcZwjkybwtHsDrhL8Gr-NQpK71S9ScM0jLIuiz0u3eT4fmUIigVvdChTUph0owdkJuyyMFBnWM5yGIqUW40qEB8bdKC8dI3FsWxkgkolILOrN5mAisauk3yApEIKqDfCd21kLBpqFCA2AAIUIfgLK45dM2LexrsSM0EKOG6dWWNc7t8zNbFOarvoKZLxywiNgded5IDYKxYGUEVKDwX5lBeAAKDrUwASjYJbWX+0mgZB9jCs44qrNOnyKS0AJkKcMsggLJ6gn4rNhNOI4TmSfrrkG5bMLS0bwq2wGMHJ7sqZpunmYZpnWfZrQuZ54KVtCg3xqy43UFNymIBh0Xf2sEkfH4rQOruuraQ8IIdTV49cz4iT0ySO7CaHJ29ZG3CNsyybPcFsHIYIaHhcs-3phxDE4gvLNZk83r7APfjM3SE9hNus0HACHJGTIdA4EsR2wATP2ToD57YnTeGczzfEdUamzKGWfE1mEzxhNzFPfvBKRIXyIfjuXDNdmlk5kd6lJMnSHUDkrqkcTNHxbsUgafuJptd4XYflxqtd4NmaSNYSJqQs8E4jxBanjYS254jmm1i-O8b8xx8xznvEqo8vB1XXJqNU6QUhrFsNVQsXVKB0jVGWJyUsbqd2fkTeBD5gxPhdCgsWo8Ly7F-hJekQQFIY2viqVMKx7LrAcBvYmpQc7YDzkw0uUE9QnGWA4aqeJDjiWOC4C4nkki+E3O1ERQ1nb60zqgKRI9phdTYYJBwIkvBiSeo1PwLgTybgrKqTyVJbC6N1qtbChjtK6WIvRRixjlxeFTAvZUQQgjZmUU9PGa5nG5juldTw10PEqX0RnMaWcjaZKMR-fe1kyRHl8JHLqElCROHEqSWCuZTgawvCsLWdYda81DPzE2ki8moOmPg5wiEem3RavEBWWxzhYnxE5C4ARq6zFSWnLx61UCbXdjkr2oMgnWQSEeDIFZm4nhsjghuLhswt3iHVbcxwu5ZCAA */
createMachine(
{
context: {
todos: [] as string[],
errorMessage: undefined as string | undefined,
createNewTodoFormInput: "",
todoToDelete: "",
},
tsTypes: {} as import("./todoAppMachine.typegen").Typegen0,
schema: {
events: {} as
| { type: "Create new todo" }
| { type: "Form input changed"; value: string }
| { type: "Submit" }
| { type: "Delete todo"; value: string }
| { type: "Back" },
services: {} as {
loadTodos: {
data: string[];
};
saveTodo: {
data: void;
};
deleteTodo: {
data: void;
};
},
},
initial: "Loading Todos",
states: {
"Loading Todos": {
invoke: {
src: "loadTodos",
onDone: [
{
target: "Show Todos",
cond: "Has todos",
actions: "assignTodosToContext",
},
{
target: "Creating new todo",
},
],
onError: [
{
target: "Loading todos errored",
actions: "assignErrorMessageToContext",
},
],
},
},
"Show Todos": {
on: {
"Create new todo": {
target: "Creating new todo",
},
},
states: {
"Deleting todo": {
invoke: {
src: "deleteTodo",
onDone: "#Todos Machine.Loading Todos",
onError: {
target: "#Todos Machine.Deleting todo errored",
actions: "assignErrorMessageToContext",
},
},
},
idle: {
on: {
"Delete todo": {
target: "Deleting todo",
actions: "assignTodoToDeleteToContext",
},
},
},
},
initial: "idle",
},
"Loading todos errored": {},
"Creating new todo": {
initial: "Showing form input",
states: {
"Showing form input": {
on: {
"Form input changed": {
actions: "assignFormInputToContext",
},
Submit: {
target: "Saving todo",
},
},
},
"Saving todo": {
invoke: {
src: "saveTodo",
onDone: [
{
target: "#Todos Machine.Loading Todos",
},
],
onError: {
target: "Saving todo errored",
actions: "assignErrorMessageToContext",
},
},
},
"Saving todo errored": {
after: {
"3000": "Showing form input",
},
on: {
Back: "Showing form input",
},
},
},
},
"Deleting todo errored": {
after: {
"3000": {
target: "#Todos Machine.Show Todos",
actions: [],
internal: false,
},
},
on: {
Back: {
target: "Show Todos",
},
},
},
},
id: "Todos Machine",
},
{
guards: {
"Has todos": (context, event) => event.data.length > 0,
},
services: {
loadTodos: async () => {
const result = await dbApi.getTodos();
return Array.from(result.data);
},
saveTodo: async (context, event) => {
const result = await dbApi.addTodo(context.createNewTodoFormInput);
// todos.add(context.createNewTodoFormInput);
},
deleteTodo: async (context, event) => {
const result = await dbApi.deleteTodo(context.todoToDelete);
},
},
actions: {
assignTodosToContext: assign((context, event) => ({
todos: event.data,
})),
assignErrorMessageToContext: assign((context, event) => ({
errorMessage: (event.data as Error).message,
})),
assignFormInputToContext: assign((context, event) => ({
createNewTodoFormInput: event.value,
})),
assignTodoToDeleteToContext: assign((context, event) => ({
todoToDelete: event.value,
})),
},
}
);
Allright, so that big JS object might not be the prettiest thing in the world. However it centralizes app logic, and completely decouples it from the UI components! It might look clunky, but is simpler than what we usually end up with on our own. With the studio it is quite easy to reason about and edit as well.
Another useful feature is to simulate how our app will respond to different events, even if we hadn't written any UI for it yet:
We can identify undesired behaviour in our app, if we look closely at the simulation video:
There are no arrows (transitions) out of this state, so if our loadTodos service errors, our app can't recover. Let's fix that!
First, the app logic:
And then for the UI:
{state.matches("Loading todos errored") && (
<div>
<p style={{ color: "red" }}>{state.context.errorMessage}</p>
<p>Hold on tight, will try again soon.</p>
</div>
)}
This will make the app retry loading the todos, and the UI informs the user what is going on. But it will loop forever, or until our service is up again. Let's try to improve this a bit.
First model/diagram:
And then code changes (not generated by the studio/diagram) in todosMachine.ts and App.tsx:
// todosMachine.ts:
...
context: {
...
retries: 0, // we keep track of data in this context object in Xstate
},
...
...
guards: {
...
"not max retries": (context, event) => context.retries < 5,
}
...
...
actions: {
"increase retries count": assign((context, event) => ({
retries: (context.retries += 1),
})),
"reset retries count": assign((context, event) => ({
retries: 0,
})),
...
}
...
// App.tsx:
...
{(state.matches("Loading Todos") ||
state.matches("Retry loading todos")) && <p>Loading...</p>} // Keep loading message if retrying
...
...
{state.matches("Loading todos errored") && (
...
<p>Experiencing som issues, please come back later.</p>
...
)} // Change text
...
On the way we got excellent Typescript support from Xstate:
Now we:
We could improve this further, but let's stop here for the sake of this article.
I hope this small exercise has demonstrated how Xstate & Statecharts can:
I would also like to point towards other benefits:
One key feature of State Machines / Statecharts is that our application can be in exactly one state at any given time. And that one state determines which "actions / signals / events" our application responds to and how. This reduces bugs by:
Designers, developers and other stakeholders can all contribute. During R&D we can produce a unique artifact that draws a clear path towards the end goal of producing software that solves the exact problems that we aim to solve. We all speak the common language of boxes and arrows! This artifact can be version controlled, and live inside our codebase, driving the behaviour of our application. In fact all stakeholders can contribute to this throughout the lifefime of the software.
Nothing is perfect, and there are some challenges which are good to be aware of:
We are used to spending a relatively short amount of time planning before we code. We deal with complexity later on, as the codebase grows. Getting started though, is easy and lightweight.
Using Statecharts, it's flipped. We start out with a bunch of planning. What is this weird cognitive load, before writing a single line of code? That stuff is supposed to come later. How could this be any fun!? We are coders are we not?
I believe this is a normal initial reaction to the unfamiliar way of working with Statecharts. I also think it passes when you experience the benefits of dealing with complexity upfront, simplifying code and reduce the amount of custom abstractions we have to build ourselves :)
The learning curve for Statecharts and Xstate has been pretty steep, but that was before 2022. The Stately Team has done a tremendous job in flattening it. With the introduction of the Stately Studio, VSCode extension, excellent Typescript support, YouTube channel and last but not least, a super helpful tutorial section right in the Studio:
Xstate supports
the Actor model, and a machine is an actor that can send and recieve messages. This
makes it possible to have smaller machines / diagrams that communicate
with each other through messages. The Actor model is powerful, and
should make sense from a modeling consideration. Not a
mechanism for keeping diagrams readable.
Disclaimer: I have only written a small handful of Xstate
machines, I might not know what I am talking about.
So, that means diagrams can easily become quite large:
Wasn't the whole point to simplify things? LOL!
True, that looks complex. But it is a simplification of what we usually do, let me explain with 2 arguments:
They paint a map of the inherent complexity of the problem at hand. How often can we successfully, and accurately, describe how an application functions, including edge cases, by looking at code?
Closely related to the previous point. Reading large amounts of code, top to bottom, file by file is tedious, hard and not easy to get right. Zooming, scrolling and following arrows is still an improvement for understanding application logic.
And last but not least regarding large machines: The Stately Team is making improvements each day, and they are exploring ideas, like collapsable states / areas, from their open feedback ➝ roadmap thingy:
I'm confident they will cook up something great for this!
I hope this article has peaked your interest for Statecharts! And that you would like to incorporate them into your next software endeavour! Xstate is framework agnostic, and runs anywhere Javascript runs. Backend / Frontend / Embedded Systems, all can benefit!
Statecharts also enables new possibilities like:
I would encourage you to watch this excellent talk by David Khourshid, creator of Xstate and founder of Stately.
These predate the Visual Editor + Typescript support, so the DX is not super top notch yet, but they are a great way to learn about the concepts of Statecharts, and get hands on practice solving problems with Xstate code.
I haven't defined some of the terms I use in this article, and while not getting too extensive here is an overview:
A model of computation, developed in the 1940/50s I believe. Consists of 5 things:
Invented by David Harel in early 1980s. Statecharts are essentially finite statemachines, but with a notation that makes them manageable and scalable.
An implementation of Statecharts & the Actor model in JavaScript / Typescript
The company behind Xstate, it's visual editor and online tools.
Any feedback is appreciated and can be given on Twitter or Github Discussions.