How state machines make it easier to reason about complex application state.
While the details of writing good code are highly subjective, a good basic rule is to try and optimize your implementations for correctness and clarity.
The code needs to accomplish the task for which it was written, and it needs to be done in a way that is straightforward, so that it can be effectively refactored, shared and extended.
We spend a fair amount of our lives as developers trying to prove to ourselves that our code respects these two tenants.
We write tests and run linters to provide confidence in our code’s correctness and sprinkle comments and documentation in our implementations to help others (and often our forgetful selves) understand the more complicated or hacky pieces that make up our applications.
It has been said in the programming community that:
Shared mutable state is the root of all evil.
If that’s the case, then the application state we write, refactor, and share with other programmers every day makes myself and most other developers very evil people.
But of course that’s not the case, and in fact, there are many things we can do to make the shared mutable states of our applications clearer and more correct. Let’s start with a simple example:
if (enabled) {
} else {
}
You: 2!
Me: You’re right!
if (enabled && !eligible) {
} else if (eligible) {
}
You: 4?
Me: Right again!
But how many of those states are we explicitly handling? Only 2. What happens in those other 2 cases? Usually nothing, but potentially errors that we haven’t accounted for, or that we’ll uncover when we add new functionality.
This is an example of how from the basic conditionals we first learn to the complex systems we spend hours reviewing and testing, we are programmed to program for happy paths.
Without a structured approach to iterating the states our code can be in it can be very difficult to make sure that you have all of your bases covered. I’d argue that most, if not all developers have stumbled upon routines consisting of questionably named flag variables used in convoluted combinations whose author is either long gone or too promoted to ask anymore.
So what techniques can we use to help us structure clear and correct application state? One approach our team has tried in the past is to use state machines.
The concept of a state machine is fairly simple. While combinations of conditionals create inexhaustive, implicit applications states, state machines require explicit and comprehensive definitions of all the application states and valid transitions between them in a system.
Our team’s first state machine used the javascript state machine (JSM) library to define our application’s authentication process. For a React-Native application like ours, that involved managing a lot of different application states ranging from account registration and sign-in, to biometrics, deep-linking, backgrounding, and offline support, not to mention the nuances of the respective iOS and Android platforms.
Our previous authentication flow had consisted of a lot of nested conditionals and long function bodies and comparatively our state machine approach had some immediate advantages:
Our state machine configurations looked something like this:
const auth = new StateMachine({
init: 'UNAUTHENTICATED',
data: {},
states: {
AUTHENTICATED: { name: 'AUTHENTICATED' },
UNAUTHENTICATED: { name: 'UNAUTHENTICATED' },
SIGN_IN: { name: 'SIGN_IN' },
REGISTER: { name: 'REGISTER' },
...
},
transitions: [
{ name: 'signout' from: 'AUTHENTICATED', to: 'UNAUTHENTICATED' },
{ name: 'signin' from: 'SIGN_IN', to: 'AUTHENTICATED' },
...
],
methods: {
onEnterAuthenticated: () => doX(),
onEnterSignIn: () => showSignInScreen(),
...
}
});
auth.transition('signin');
The JSM configuration consumes a set of states and transitions, as well as lifecycle handlers for entering, leaving and transitioning between states. Each step in our authentication system received its own state and the business logic we need to perform was executed in the onEnterState
lifecycle handlers.
While it was definitely an improvement from our previous implementation, over time as we modified and added new steps to the authentication process it once again became difficult to work with and we had to overcome a new set of problems inherent to state machines:
Authenticated/Foregrounded | Unauthenticated/Backgrounded |
Authenticated/Backgrounded | Unauthenticated/Backgrounded |
Poor separation of concerns: All of the authentication logic is in a single big state machine. The biometrics logic is co-located with the logic for the sign-in and deep-linking states and overall the authentication state machine had too many responsibilities.
Big graphs and lines everywhere: Our purpose for using state machines was to make it easier to reason about and organize complex systems. Looking at a diagram with 10 states and a spider web of transitions doesn’t help much to lower that cognitive burden.
statecharts are a layer on top of state machines that enhance the core concept of a graph-based definition of system states with additional features and patterns for managing complexity.
While the JavaScript community is known for being quick to embrace new and unproven technologies, a formalization for the statecharts model was introduced decades ago in this public paper and the system has already applied in a number of real-world scenarios:
When NASA used the Statechart Autocoder to build the software for the Mars Science Laboratory (the Curiosity Rover), one of the reported benefits of using Statecharts was that “it forced developers to consider off nominal scenarios”.
Whether you call them bugs, features, or off nominal scenarios, statecharts can help developers identify edge cases and write clearer state machine implementations.
Before we implement a statechart, we’ll need to become familiar with some of its core terminology:
statecharts address the problem of poor-encapsulation by supporting hierarchies of states.
A compound state like a user’s activity in a form can be broken down into a number of substates like name, birthdate, address and others.
To address the state explosion issue, statecharts support parallel states. A parallel state node is in all of its child states at the same time.
A system of boolean conditions like authenticated/unauthenticated and foregrounded/backgrounded will now grow linearly as opposed to exponentially and can better represent the state of the system, since a user can be both authenticated and backgrounded simultaneously.
One of the biggest advantages of the statechart model is its translation into a clear and formalized visual statechart diagram:
The diagrams follows the W3C statechart XML (SCXML) implementation and can clearly illustrate every part of a state machine configuration including parallel and hierarchical states.
xstate is a JavaScript implementation of the statechart model. It describes itself as:
A library for creating, interpreting, and executing finite state machines and statecharts, as well as managing invocations of those machines as actors.
xstate implements the statechart model with all of its core feature specifications:
It additionally models each of its state machine objects as actors. The actor model is a model of concurrent computation that treats “actors” as the universal primitives of a system, with each actor able to perform three core functions:
Each actor encapsulates its own logic and exchanges information with other state machines through events. In this way, an active state machine can be thought of similarly to a process running on your system, unable to access data from other services directly but able to share input and output between them.
Let’s see how xstate can be used to model one of our actual button components. Our example code will be using React, but because its implementation is self-contained, it can be reused in your frontend framework of choice.
const Button = ({
onPress,
enabled,
isLoading,
}) => {
return (
<div
className={css(getContainerStyle(enabled).container)}
onPress={() => onPress()} // Optimizing here for example lines not performance
disabled={!enabled || !onPress || isLoading}
>
{
isLoading ? 'Loading...' : 'Submit'
}
</div>
);
};
The button component has 3 inputs:
It consumes these inputs to determine which one of the three mutually exclusive UI states to display: loading, enabled or disabled.
We write conditional logic like this all the time to model implicit state machines. While this approach generally works well as complexity remains low, even in a relatively simple button component we can identify some edge cases:
Now let’s compare this to an explicit state machine model using xstate. We’ll start with the state machine definition:
const transitions = [
{ target: "#button.enabled", cond: (context, event) => !event.isLoading && event.onPress && event.enabled },
{ target: "#button.disabled.loading", cond: (context, event) => event.isLoading },
{ target: "#button.disabled.manual", cond: (context, event) => !event.enabled },
{ target: "#button.disabled.unhandled", cond: (context, event) => !event.onPress }
];
const ButtonMachine = Machine({
id: "button",
initial: "enabled",
states: {
enabled: {
on: {
UPDATE: transitions
}
},
disabled: {
id: "disabled",
initial: "init",
states: {
init: {},
loading: {},
manual: {},
unhandled: {}
},
on: {
UPDATE: transitions
}
}
}
});
const { initialState } = ButtonMachine;
console.log(initialState.value);
// => 'enabled'
const nextState = ButtonMachine.transition(initialState, 'UPDATE', { isLoading: true });
console.log(nextState.value); // => disabled.loading
This configuration sets up an atomic enabled state and a compound disabled state. On receiving an UPDATE
event, the state machine goes through
the transitions array until it finds the first transition that satisfies its condition and executes that transition.
The transition API is a pure function which takes an initial state and an event and returns the new state of the system. While a state machine with a pure .transition()
function is useful for flexibility, purity, and testability, in order for it to have any use in a real-life application, something needs to:
An interpreter is responsible for interpreting the state machine/statechart and doing all of the above - that is, parsing and executing it in a runtime environment. An interpreted, running instance of a statechart is called a service.
Now that our conditional logic has been abstracted, we can focus on writing the plain presentational layer:
const Button = ({
onPress,
enabled,
isLoading,
}) => {
const [current, send] = useMachine(ButtonMachine);
const disabledState = _.get(current, 'value.disabled');
const isEnabled = _.get(current, 'value') === enabled;
useEffect(() => {
send("UPDATE", { onPress, enabled, isLoading });
}, [enabled, isLoading, send, onPress])
return (
<div
className={css(getContainerStyle(isEnabled).container)}
onClick={() => onPress()}
disabled={!!disabledState}
>
{disabledState === "loading" ? "Loading..." : "Submit"}
</div>
);
}
The useMachine
API from @xstate/react
interprets the given state machine and starts it as a service:
import { interpret } from 'xstate'
import { useRef, useState } from 'react';
export const useMachine = stateMachine => {
const [state, setState] = useState(stateMachine.initialState);
const ref = useRef(null);
if (ref.current === null) {
ref.current = interpret(stateMachine);
}
useEffect(() => {
if (!ref.current) {
return;
}
ref.current.subscribe(setState);
ref.current.start();
return () => {
ref.current!.stop();
ref.current = null;
};
}, [stateMachine]);
}
When we receive new props in our component, we send them to the running state machine service as an UPDATE
event.
This explicit state machine has three main benefits over our first implementation:
enabled
, disabled.loading
, disabled.manual
or disabled.unhandled
)enabled=false
prop change,
it will not transition from the loading state.Here is a diagram of our button state machine:
The ability to visualize the states of a UI component or experience can benefit the entire team.
statecharts are also useful tools for managing complex flows. For our development focusing on financial products, an example of such a flow could be an application for a new bank account.
This financial product will consist of a number of screens the user goes through to enter their personal information required for the application:
import { Machine, assign } from 'xstate';
const BankApplicationMachine = Machine(
{
initial: 'NAME',
context: {
name: null,
dateOfBirth: null,
SSN: null,
address: null,
},
states: {
NAME: {
entry: 'showNameScreen',
on: {
SUBMIT: {
target: 'DATE_OF_BIRTH',
actions: assign((context, { name }) => ({
...context,
name,
}))
}
}
},
DATE_OF_BIRTH: {
entry: 'showDOBScreen',
on: {
SUBMIT: {
target: 'SSN',
actions: [assign((context, { dob }) => ({
...context,
dob,
}))]
}
},
},
SSN: {
entry: 'showSSNScreen',
on: {
SUBMIT: {
target: 'ADDRESS',
actions: [
(context, { ssn }) => assign({ ssn }),
],
}
}
},
ADDRESS: {
entry: 'showAddressScreen',
}
},
},
{
actions: {
showNameScreen: (context, event) => Navigation.push('NameScreen'),
showDOBScreen: (context, event) => Navigation.push('NameScreen'),
showSSNScreen: (context, event) => Navigation.push('NameScreen'),
}
}
);
Our above state machine generates the following visualization:
There a couple of new features to unpack here:
What if we wanted to allow a user to stop and resume applications for multiple financial products at a time?
As actors, each statechart service can spawn and manage its own child services. We can create a BankApplicationManagerMachine
to handle multiple applications:
import { Machine, spawn, assign } from 'xstate';
const BankApplicationManagerMachine = Machine({
context: {
applications: {},
activeApplication: null
},
states: {
idle: {},
opened: {},
},
on: {
OPEN_APPLICATION: {
target: 'opened',
actions: assign((context, event) => {
let application = context.applications[event.name];
if (application) {
return {
...context,
activeApplication: application,
};
}
application = spawn(BankApplicationMachine);
return {
applications: {
...context.applications,
[event.name]: application
},
activeApplication: application,
};
})
}
}
});
Our UI can then start the manager service and let the user switch between applications:
import { useMachine, useService } from '@xstate/react'
import { useMemo, useCallback } from 'react';
const products = [
{ name: 'Chase Bank', id: 'chase' },
{ name: 'Bank of America', id: 'bank-of-america' }
];
const Application = ({ service }) => {
const [current, send] = useService(service);
if (current.matches('NAME')) {
return (
<NameForm
initialData={current.context.name}
onSubmit={(data) => send('SUBMIT', data)}
/>
);
} else if (current.matches('DATE_OF_BIRTH')) {
return (
<DateOfBirthForm
initialData={current.context.dateOfBirth}
onSubmit={(data) => send('SUBMIT', data)}
/>
);
}
...
}
const ApplicationManager = () => {
const [current, send] = useMachine(BankApplicationManagerMachine);
const { activeApplication } = current.context;
const handleOpenApplication = useCallback(
product => send('OPEN_APPLICATION', { product }),
[send]
);
const renderApplicationPicker = useMemo(() => products.map(product => (
<a onClick={() => handleOpenApplication(product.name)}>{product}<a/>
), [handleOpenApplication]);
return (
<div>
{renderApplicationPicker()}
{activeApplication && <Application service={activeApplication} />}
</div>
);
};
Storing spawned applications and their data in a state machine separately from the UI has a number of advantages over a traditional data-in-components implementation:
Whether you have written state machines before, there are always state machines in our code, either implicit or explicit.
Implicit ones are generally harder to maintain, visualize and get right. In theory, an explicit state machine can help developers optimize for correctness and clarity in their code. In practice, it is important to point out the potential downsides to relying on state machine implementations:
The investigation and examples we’ve looked at here make the case that the benefits of using state machines outweigh these concerns and ultimately present a compelling case for managing the shared, mutable states of our applications through explict state machines.
There are excellent resources on both statecharts and xstate that I would recommend exploring if you’re interested in the topic: