How to Use `useReducer` as a Finite State Machine
I recently attempted to get XState into the Webflow codebase to manage some challenging and complex UIs but was met with resistance. No big deal, this is a part of work, consensus driving, disagreeing and committing, etc. If it hasn’t happened to you yet, it will.
One of the things you need when that happens is a good imagination. How do we make the tools we already have do the things we want in a reasonable way? I’m going to help you with that today by showing you a way to make useReducer
behave like a simple finite state machine in React.
I’ve started using the following pattern in some of my work and I think it will help you. If we look at the reducer
of useReducer
and a state machine, we can see that they are quite similar. They both take a current state
and an event
(Redux calls this an action
, but I will use event
throughout this post), and return the next state. The differences are:
- A
reducer
does not restrict itself to a set of finite states - A
reducer
has no mechanism for calling side effects from state transitions
We can’t do anything about the second point, and I will cover that in a future post on useEffectReducer
, but we can do something about the first. We can create a graph of states that our reducer
will use to transition to the next state. I’ll demonstrate how I approach this.
I’m going to use the example of a light bulb like I do in my XState course. A bulb has three states: lit
, unlit
, and broken
. Let’s make a NEXT_STATE_GRAPH
object starting with these states.
const NEXT_STATE_GRAPH = {
lit: {},
unlit: {},
broken: {},
}
Next, let’s define the events
that will transition each state
to their next state.
const NEXT_STATE_GRAPH = {
lit: {
TOGGLE: 'unlit',
BREAK: 'broken',
},
unlit: {
TOGGLE: 'lit',
BREAK: 'broken',
},
broken: {},
}
Now, we’re going to use this in the reducer
we will pass to useReducer
to get our next state.
const reducer = (state, event) => {
const nextState = NEXT_STATE_GRAPH[state][event]
return nextState !== undefined ? nextState : state
}
That’s it! A reducer
doesn’t need to be complicated or use a switch
statement. It just has to deterministically give you the next state. Let’s apply this to a simple component.
function LightBulb() {
// we can change `dispatch` to `send` to look more like using XState
const [state, send] = useReducer(reducer, 'unlit')
return (
<div>
State: {state}
<button type="button" onClick={() => send('TOGGLE')}>
Toggle
</button>
<button type="button" onClick={() => send('BREAK')}>
Break
</button>
</div>
)
}
You can see it in action right here. Be sure to click buttons a state shouldn’t respond to as well!
Types
One small issue I ran into while implementing this was that our type system, Flow, was not happy that I was using the failure of finding a key on an object to return undefined
on purpose. Type systems hate when you use a language feature like that. No problem, I made a simple fix by adjusting our NEXT_STATE_GRAPH
slightly.
// Let's make an object of all our events with an undefined next state
const NON_RESPONSIVE_EVENTS = {
BREAK: undefined,
TOGGLE: undefined,
}
// Now let's spread that object into all our states
const NEXT_STATE_GRAPH = {
lit: {
...NON_RESPONSIVE_EVENTS,
BREAK: 'broken',
TOGGLE: 'unlit',
},
unlit: {
...NON_RESPONSIVE_EVENTS,
BREAK: 'broken',
TOGGLE: 'lit',
},
broken: {
...NON_RESPONSIVE_EVENTS,
},
}
Now each state node will have a key for any event
and explicitly returns undefined
in those situations where we do not want to make a state transition. We simply override the non-responsive events
with responsive ones below, taking advantage of how object spreading works. This will appease any exhaustive check a type system demands.
Conclusion
There you have it, a simple finite state machine using useReducer
. I’ll make another post soon on how to handle infinite state data alongside finite states with useReducer
, but this should be enough to get your brain juices flowing and experimenting with this approach.