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
reducerdoes not restrict itself to a set of finite states - A
reducerhas 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.