Adding Infinite States to a `useReducer` Finite State Machine
Before reading this post, I encourage you to read How to Use useReducer
as a Finite State Machine if you haven’t already. In that post, I demonstrate how to make the React Hook useReducer
behave like a finite state machine using a statechart-like graph. I’m going to expand upon that work today.
The next thing we need to add to our useReducer
-based finite state machine is contextual data. Not all data can be modeled as a set of finite states (nor should it). Consider the humble <input />
. A user can provide an infinite number of strings as a value
to the input. By definition, we cannot model the infinite with the finite, and thus we need a way to store infinite contextual data in our machine. It will be very useful to us when we add guards and conditions in a future post.
In XState, infinite state data is stored on the context
object of the state machine. Because React also has a concept of context
and I do not wish to conflate the two concepts, so I will simply call this data
. This means that our state machine’s state
is not the only thing being returned by our reducer
anymore, and so we need a name for that as well. In this post, I will call this current
, as in the “current state”. Let’s make these our first changes to our state machine. We’ll continue to use the LightBulb
from the previous post as the object that we are modeling.
const initial = {
current: 'unlit',
data: {},
}
//...
const reducer = (state, event) => {
const { current } = state
const nextState = NEXT_STATE_GRAPH[current][event]
return nextState !== undefined ? nextState : state
}
//...
function LightBulb() {
const [state, send] = useReducer(reducer, initial)
const { current } = state
return (
<div>
State: {current}
<button type="button" onClick={() => send('TOGGLE')}>
Toggle
</button>
<button type="button" onClick={() => send('BREAK')}>
Break
</button>
</div>
)
}
Our light bulb continues to work as it did before, so now let’s modify it so that we need to store some data
and make use of it. Here is the specification we’re going to build into our reducer:
- If the
nextState
isundefined
, do not update thecurrent
state ordata
- Otherwise, update the
state
and update thedata
if necessary
Let’s modify our reducer
to accommodate this:
const reducer = (state, event) => {
const { current, data } = state
const nextState = NEXT_STATE_GRAPH[current][event]
if (!nextState) return state
const nextData = data // we need to write the code that will update this still
return {
current: nextState,
data: nextData,
}
}
We’ve laid the foundation for updating data
in response to an event
, but our code does not support passing data
along with an event
yet. At the moment, an event
is merely a string. We need to be able to pass more information along with the event. How can we support being able to use a string for an event, and also pass data
with it?
By making every event
an object under the hood.
This is one of my favorite features of XState and a pattern that’s easy to borrow. All we need to do is turn an event
like TOGGLE
into an object like { type: 'TOGGLE' }
under the hood. This will allow the user to supply simple strings if no data needs to be on the event, or an object with the additional data. We accomplish this by normalizing the events into the same shape.
const toEventObject = event =>
typeof event === 'string' ? { type: event } : event
We now use toEventObject
in our reducer
to normalize the event
before using it to determine the nextState
.
const reducer = (state, event) => {
event = toEventObject(event)
const { current, data } = state
const nextState = NEXT_STATE_GRAPH[current][event.type]
if (!nextState) return state
const nextData = data
return {
current: nextState,
data: nextData,
}
}
Now that we’ve normalized the event
into an object, we’ll be able to pass along extra data on that object when we send
event
s to our machine. Let’s modify our example so that our LightBulb
needs some contextual data. Let’s add color
to our light bulb so that when it’s lit
, we can change the color
with a CHANGE_COLOR
event.
This is where I’m going to introduce several things and start to make some deviations from XState, so bear with me.
We now have an event that will not change the state of the light bulb. Sending a CHANGE_COLOR
event should keep us in a lit
state. Since our state graph depends on having a defined value for an event, we need to add the CHANGE_COLOR
event to our NEXT_STATE_GRAPH
object. I’m going to add a RESET
event while we’re at it, and use object spreading to dry up some events that are used in multiple states.
const BREAK_EVENT = { BREAK: 'broken' }
const RESET_EVENT = { RESET: initialState.current }
const NEXT_STATE_GRAPH = {
lit: {
...BREAK_EVENT,
...RESET_EVENT,
CHANGE_COLOR: 'lit', // Notice we stay in the same state
TOGGLE: 'unlit',
},
unlit: {
...BREAK_EVENT,
...RESET_EVENT,
TOGGLE: 'lit',
},
broken: {
...RESET_EVENT,
},
}
We also need a way for CHANGE_COLOR
to trigger an update to our data
. The way I’m choosing to do this is with an object called DATA_UPDATERS
. Each updater
function will receive the current data
and the event
that triggered the updater
call.
const DATA_UPDATERS = {
CHANGE_COLOR: (data, event) => ({ ...data, color: event.color }),
RESET: () => initial.data,
}
Now, we need to update our reducer
to actually use our DATA_UPDATERS
. I’m going to make a slight refactor here as I do it where I will “split the loops” of our state reduction and our data reduction into two separate reducers.
const stateReducer = (state, event) =>
NEXT_STATE_GRAPH[state.current][event.type]
const dataReducer = (data, event) => {
const updater = DATA_UPDATERS[event.type]
return updater ? updater(data, event) : data
}
const reducer = (state, event) => {
event = toEventObject(event)
const nextState = stateReducer(state, event)
if (!nextState) return state
const nextData = dataReducer(state.data, event)
return {
current: nextState,
data: nextData,
}
}
Now we can add some UI to our HueLightBulb
that supports changing colors.
const EVENTS = ['TOGGLE', 'BREAK', 'RESET']
const COLORS = [
{ name: 'white', value: '#feffeb' },
{ name: 'red', value: '#ff674f' },
{ name: 'blue', value: '#5cb6ff' },
{ name: 'green', value: '#8ff244' },
]
function HueLightBulb() {
const [state, send] = useReducer(reducer, initial)
const { current, data } = state
return (
<div>
<span>State: {current}</span> <span>Color: {data.color}</span>
<div>
<span>Events</span>
<div>
{EVENTS.map(event => (
<button key={event} type="button" onClick={() => send(event)}>
{event}
</button>
))}
</div>
</div>
<div>
<span>Change colors</span>
<div>
{COLORS.map(({ name, value }) => (
<button
key={name}
type="button"
onClick={() => send({ type: 'CHANGE_COLOR', color: value })}
>
{name}
</button>
))}
</div>
</div>
</div>
)
}
Now we’re able to change the colors of our bulb, but only when we are in the lit
state. We have a useReducer
-based state machine that can handle infinite state data!
I made a component for you to play with that contains a few extra bells and whistles, including some SVGs that I made to show all the states of our bulb.
{
"current": "unlit",
"data": {
"color": "white"
}
}
If you prefer to tinker with the code itself, an iteration of this example exists on Codesandbox for you to fork: https://codesandbox.io/s/usereducer-fsm-with-infinite-states-v2b1g
Conclusion
Infinite data cannot be modeled as a finite set of states, but we still want to have the control and guarantees a state machine gives us about our program. We can achieve this by storing this extra data
in our state
. We can make updates to this data
through event
objects. We only make updates to our data
when our state graph allows it, and we guarantee the deterministic output of our state
through reducers for our current
state and our data
.
In a future post, we’ll add the concept of guards to our state machine that will require some modifications to how we think of our contextual data
, but I hope this continues to inspire your imagination on how to write more robust programs in the mean time.