May 15, 2020

Adding Infinite States to a `useReducer` Finite State Machine

edit

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 is undefined, do not update the current state or data
  • Otherwise, update the state and update the data 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 events 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"
  }
}
Events
Change Colors

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.


Liked the post?
Give the author a dopamine boost with a few "beard strokes". Click the beard up to 50 times to show your appreciation.
Kyle Shevlin's face, which is mostly a beard with eyes

Kyle Shevlin is the founder & lead software engineer of Agathist, a software development firm with a mission to build good software with good people.

Logo for Introduction to State Machines and XState
Introduction to State Machines and XState
Check out my courses!
If you enjoy my posts, you might enjoy my courses, too. Click the button to view the course or go to Courses for more information.
Sign up for my newsletter
Let's chat some more about TypeScript, React, and frontend web development. Unsubscribe at any time.