erikras.com
HomeAbout

A reducer is a single-state state machine

If you write a state machine that only has a single state, but modifies its context, what you've created is a reducer.

Posted in Coding, ReScript, Finite State Machines
March 23, 2021 - 3 min read
A reducer is a single-state state machine

It's hard to state just how difficult it is to talk about state management; it all depends on the context of the conversation. The more I have been thinking about state machines, and learning about XState, the more I like the way XState talks about "application state".

In the context (🙄) of state machines:

state: which of a finite number of states the machine is currently in. e.g. fetching, loaded, error.

context: the environment in which events are taking place. This includes which user is logged in, any data that has been loaded, and errors that have been returned.

Notice how these are the same words that React uses for different things. The state machine "context" is what you would put into a useState() hook. In Redux, the state machine "context" would be in your store.

Let's build the simplest possible state machine, in my new favorite language, ReScript:

The Ubiquitous Counterexample

In ReScript, our states, represented by variants, can have a payload, which is where we will place our context. In this case, our context is a simple integer.

type state = Idle(int)
type event = Increment | Decrement | Reset
export let initial = Idle(0)
export let transition = (state:state, event:event) =>
switch (state, event) {
| (Idle(value), Increment) => Idle(value + 1)
| (Idle(value), Decrement) => Idle(value - 1)
| (Idle(_), Reset) => initial
}

Notice that we are always returning to our only state, Idle, and our events are only modifying our context.

A single-state state machineAmerican Idle

What have we created here? A simple reducer. This is the equivalent, in TypeScript, of:

export const initial = 0;
export function transition(
state: number,
event: "Increment" | "Decrement" | "Reset"
) {
switch (event) {
case "Increment":
return state + 1;
case "Decrement":
return state - 1;
case "Reset":
return initial;
}
}

It's the kind of thing that powers Redux or what you might give to a useReducer() hook in React.

I can hear you rolling your eyes already.

Yeah, so what?

Well, what if we wanted to temporarily disable our counter, so that the value couldn't be changed? You could add another disabled: boolean to your reducer's state value... But if you were implementing it as a state machine, you could have your Pause event shift the machine into the Paused state, where it would ignore the Increment and Decrement events.

type state = Idle(int) | Paused(int)
type event = Increment | Decrement | Reset | Pause | Unpause
export let initial = Idle(0)
export let transition = (. state:state, event:event) =>
switch (state, event) {
| (Idle(value), Increment) => Idle(value + 1)
| (Idle(value), Decrement) => Idle(value - 1)
| (Idle(_), Reset) => initial
| (Idle(value), Pause) => Paused(value)
| (Paused(value), Unpause) => Idle(value)
| (Idle(_), Unpause)
| (Paused(_), _) => state
}

These are very, very trivially simple examples, but I think there may be a significant difference "at scale" with a real application.

Discuss on Twitter

© 2024 Erik Rasmussen