Strongly-typed finite-state machines with Redux and TypeScript
Finite-state machines have been all the rage recently. There are many libraries that allow you to work with finite-state machines. However, I wondered: how far can we get with our existing tools — Redux and the reducer pattern?
Note that this article is not concerned with why finite-state machines are useful. If you’re interested in learning about why they are useful, I recommend David Khourshid’s talk “Simplifying Complex UIs with Finite Automata & Statecharts”.
Reducer is a state machine
At the core of state machines is the following function:
If you’re familiar with Redux, that might look familiar to you. A Redux reducer function is a state machine! A reducer function describes how the machine should transition, given the previous state and an action (aka an event in statechart terminology), to the next state.
This article is interested in how we can utilise Redux to write a strongly-typed finite-state machine, in the interests of code correctness and readability. By finite, we mean that the machine may only be in one of a finite number of states at any given time. By strongly-typed, we mean that the states and actions should carry the types of their parameters, and these parameters should only be accessible when we’ve narrowed the union of states or actions to a single variant.
The examples in this article are available on GitHub.
Setting the scene
Throughout this article we will use the example of a simple photo gallery search. The application starts in a Form
state, where the user may enter a query. Upon form submission the Search
event is triggered, and the application should transition into a Loading
state. When request either fails or succeeds, corresponding events SearchFailure
and SearchSuccess
are triggered the application should transition into the Failed
or Success
states, respectively. We can represent this as a statechart diagram:
Prerequisites
To begin we must define some basic types and helpers which we’ll need to use later on:
Defining states
Our State
type is a tagged union of all the possible state variants. By expressing our state as a union, we're defining which states are valid. Later we'll use these states to define which transitions are valid.
Note how the query
parameter is only available in the Loading
state, and the items
parameter is only available in the Gallery
state. By restricting these parameters so that they only exist in their corresponding states, we are making impossible states impossible: the type system only allows us to access the parameters when we are in a state where they can exist.
(Note there are cleaner ways to define tagged unions in TypeScript, but I refrained from using them in this example for simplicity. See the note at the end.)
Defining actions
Our Action
type is a tagged union of all the possible action variants.
Defining transitions
Earlier we saw how Redux’s reducer functions allow us to describe our state transitions. As well how the transition should be performed, we can define which transitions are valid for given states. For example, a valid transition would be from a Loading
state, given a SearchFailure
event, to the Failed
state—but the SearchFailure
event should not cause a transition when we're in the Form
state.
Tying it all together
To demonstrate our state machine, we can simulate actions. In a real world application, these would be driven by outside events such as user interactions or HTTP responses.
This produces the following output in the console:
Going further
There is much more to state machines than this example demonstrates. However, the simple primitive of a reducer allows for all types of state machines. For example, “nested state machines” can simply be modelled as nested reducers. Likewise, “parallel state machines” are just combined reducers.
Our Action
and State
types are known as tagged union types. There are much cleaner ways of defining and using tagged union types in TypeScript, but I refrained from using them in this example for simplicity. At Unsplash we tend to use Unionize. For example, here is how we might define the Action
tagged union type if we were to use Unionize:
A full example using Unionize can be seen at https://github.com/unsplash/ts-redux-finite-state-machine-example/tree/unionize.
If you like how we do things at Unsplash, consider joining us!