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:

Generated via https://bit.ly/xstate-viz

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!