Software is almost always stateful, and that shouldn't really surprise anyone. Rarely do applications not store at least some preferences of the user, whether it is just recently used functionality, keyboard shortcuts, account associations (whether the user signed in, and which account is linked to them), and other settings.
For interactive user interfaces, we encounter much more than simple "boolean state flags", deciding whether a view is shown or hidden. We enjoy using carefully-choreographed onboarding flows spanning multiple screens and different scenarios, multi-step wizards and dialogs, and other clearly stateful parts modern user interfaces have to offer.
And this is only talking about the frontend, what about customer journeys, sales and marketing funnels, user lifecycle/drip marketing campaigns? The more we look around, the more increasingly complex stateful structures we identify.
We can also take a step back from the web and think about daily encounters with state: Think about traffic lights that run in a specific sequence, board games that adhere to specific rules, nearly anything can have a state. Needless to say, finding things that are stateless is probably much harder than observing stateful events happening around you.
Constructing and maintaining state is not always easy. Out of this need, the topic of state machines has gotten popular again and is being put to use in almost every area of delivering digital experiences. While it might seem sufficient to use a home-grown model to represent your state, once that grows to accommodate more use cases, you will quickly hit the ceiling and explore alternative solutions, look no further!
🎰 State Machines
State Machines were introduced as a theoretical concept to model a finite number of states, with transitions from one state to another (denoted using arrows). To know where to start, an initial state is declared, and if you know that there's a final state or objective that needs to be reached, you can declare a final state.
While the concept is relatively simple, you can model almost any situation with this. In addition to this, there are multiple variants of state machines, reaching from finite state machines, to Turing Machines.
Essentially, state machines allow you to model any flow of operations. To visualize state machines, you can use state diagrams (like the one I included above and below)
Transitions can also lead back to the same state, if that makes sense in some cases.
To conclude, we'll focus on finite state machines, which have
- a finite number of states, including a declared initial state, potential final states
- transitions that link one state to another with a direction
📈 State Charts
An extension to regular state diagrams, statecharts, invented by David Harel in the 1980s. Used for specifying and programming reactive systems, they allow creating of hierarchies of states, nesting state diagrams, running multiple flows in parallel, adding conditions as guards that permit certain transitions to occur, or external events like timers.
Putting all of this together, Harel modeled the internals of his wristwatch in several statecharts, one of these added below.
💻 From Formal Definitions to Applied Solutions
While there is a lot to learn about state machines, we now know about the core concepts and goals, and we can use this knowledge to check out one of the popular libraries used for creating and managing state machines in JavaScript, XState. Built to adhere to the State Machine Notation for Control Abstraction (SCXML) and Harel's original statecharts formalism, XState was built from scratch, without any additional dependencies, to offer an interface for serializable state machines.
import { Machine } from 'xstate';
const promiseMachine = Machine({
id: 'promise',
initial: 'pending',
states: {
pending: {
on: {
RESOLVE: 'resolved',
REJECT: 'rejected'
}
},
resolved: {
type: 'final'
},
rejected: {
type: 'final'
}
}
});
The primary example outlined in the docs shows a couple of important properties of our state machine: To model the state of a JavaScript Promise, which starts in a pending state and either transitions to a resolved state on success or rejected when an error occurs, we'll start by creating three states: pending
, resolved
, rejected
. pending
is assigned as the initial state for our machine, while resolved
and rejected
are both marked as final states. Once the machine transitions into these states, it's done executing.
We also define events in the pending state, which create transitions to a specific next state. In our case, the event RESOLVE
should transition to the resolved
state, while REJECT
should lead to the rejected
state.
To make our machine run, we need to interpret it. This adds the required runtime logic to keep track of state and persist it, manage background tasks and side effects, etc.
import { Machine, interpret } from 'xstate';
const promiseMachine = Machine({
/* ... */
});
const promiseService = interpret(promiseMachine).onTransition(state =>
console.log(state.value)
);
// Start the service
promiseService.start();
// => 'pending'
promiseService.send('RESOLVE');
// => 'resolved'
Invoking the interpret
functions creates an executable representation of our statechart, called a service. Services can be started and stopped, and you can send events (which you specified for your states.
After starting your service, we send a RESOLVE
event that leads the state machine to transition into the resolved
final state.
While this was a simple example, XState offers an extensive catalog of features like hierarchical and parallel statecharts, guarded (conditional) transitions, context (storing data in your state machine), activities (continuous processes like beeping, flashing a light, or showing a loading spinner), and even more.
XState has simplified creating and managing state machines in a way so that every piece of software for JavaScript, whether it's a web application, a backend service, or something completely different, can benefit from understandable, powerful, and scalable state management. And the community has gladly adopted it: As users come to expect more polished experiences, the size of state developers have to keep track of has grown a lot. Most systems we used until now got hard to comprehend, and even harder to modify.
Stepping back and taking another approach, declaring a set of potential states, initial and final states, as well as transitions instantly gives you a declarative representation of your state, potential events in each state, and conditions under which transitions to adjacent states are possible.
🎲 What's Next for State?
While some big applications have already caught on to the new patterns that emerged with libraries like XState, there's still a long way to go. Developer experience has to be one of the highest priorities, next to stability. We want battle-tested systems that as expected, and don't change too often. Abstractions can be built on top of that, of course.
And while XState is a gift for the JavaScript ecosystem, I haven't found comparable solutions for other architectures, and use cases just yet.
I'm curious how state management continues to evolve, but going back to the roots of computer science surely has surfaced some great techniques we can use to make our software much easier to maintain, and user experiences much more versatile.