May 27, 2020
0 strokes bestowed

Adding Guards to a `useReducer` Finite State Machine

edit

The next part in our useReducer finite state machine journey is adding "guards". If you haven't read the previous posts in this collection, I encourage you to do so before reading ahead. Those posts are:

A "guard" is a predicate function used to conditionally determine if a state transition should happen. In simpler terms, if the guard function returns true, take the transition, otherwise do not.

Guards can be used to prevent any transition from taking place. For example, a trying to push open a locked door won't do anything at all. Or they can be used to take one transition instead of another. The "happy path" leads to one state, the "sad path" to another.

In order to support guards, we're going to need to be able to indicate that a transition has a guard. This means that we're going to have to make a change to the state an event is targeting.

Thus far, we've only had simple transitions. For example, the event BREAK has the value broken, indicating that this event transitions to the broken state. I would like this string to be a shorthand for an object, where the string points to the event's target.

// This...
const BREAK_EVENT = { BREAK: 'broken' }

// is equivalent to this
const BREAK_EVENT = { BREAK: { target: 'broken' } }

To accomplish this, we need another function, similar to toEventObject in the previous post. We can call this one toTransitionObject. It'll basically be the same.

const toTransitionObject = transition =>
  typeof transition === 'string' ? { target: transition } : transition

This normalizes the shape of our transitions. Let's make use of this inside of our stateReducer. There will be bigger changes later, but it'll be good to start with this one while it's a simple change.

const stateReducer = (state, event) => {
  const nextState = NEXT_STATE_GRAPH[state.current][event.type]

  // We don't want to use `toTransitionObject` on undefined, so guard & early
  // return here if there's no transition
  if (!nextState) return

  const { target } = toTransitionObject(nextState)

  return target
}

Now that our finite state machine utilizes target, we can add new properties to this object and utilize them in determining if a transition should be taken. This is how we're going to start utilizing guards.

Let's add a cond property whose value is a predicate function. When the predicate returns true, we'll return the target, otherwise no transition will be taken.

To implement this, let's imagine briefly that our light bulb is indestructable. We're going to give our BREAK event a transition whose cond property always returns false, and therefore is never taken.

const BREAK_EVENT = {
  BREAK: {
    target: 'broken',
    cond: () => false,
  },
}

Now, let's write the code necessary to respect this condition and not take this transition. This will involve modifying the stateReducer further.

const stateReducer = (state, event) => {
  const nextState = NEXT_STATE_GRAPH[state.current][event.type]

  if (!nextState) return

  const possibleTransition = toTransitionObject(nextState)
  // Use default assignment to guarantee that `cond`
  // is a function and returns true if it's undefined
  const { target, cond = () => true } = possibleTransition

  if (cond()) {
    return target
  }

  return
}

With that in place, our light bulb should be currently indestructable. Try it out here.

Indestructable Bulb

{
  "current": "unlit",
  "data": {
    "color": "white"
  }
}
Events
Change Colors

No matter how many times you click that BREAK button. It's not going to break. While an indestructable bulb is awesome, it's pretty unlikely. One thing we can do is add a fuse before our light bulb that might prevent it from breaking.

In this case, we can add a hasFuse value to our data and check it's value as our cond predicate function. If our light bulb happens to have a fuse, we're going to transition to a new state, brokenFuse, instead of broken. Also, we'll rename broken to brokenBulb to be slightly more accurate.

const initialState = {
  current: 'unlit',
  data: {
    color: 'white',
    hasFuse: true,
  },
}

const BREAK_EVENT = {
  BREAK: {
    target: 'brokenFuse',
    cond: data => data.hasFuse,
  },
}

But wait! Didn't I say we want to transition to brokenBulb in the case where there isn't a fuse? We need a way to have multiple targets for an event.

The most obvious data structure for this is an array of transition objects. Let's write that, and make our code use it next.

const BREAK_EVENT = {
  BREAK: [{ target: 'brokenFuse', cond: data => data.hasFuse }, 'brokenBulb'],
}

Now our BREAK_EVENT has two targets. If our bulb has a fuse, the machine will transition to brokenFuse, otherwise it'll transition to brokenBulb. But our machine doesn't know how to handle an array of transition objects yet. How do we adjust our code so that it can handle either an array or an object?

We could write the code with a condition that checks for the type and acts accordingly, but a simpler solution is to normalize all of our transitions into an array of transition objects. This means that we want these two structures to essentially be equivalent:

// This
const RESET_EVENT = {
  RESET: 'unlit',
}

// is equivalent to this
const RESET_EVENT = {
  RESET: ['unlit'],
}

// which is equivalent to this
const RESET_EVENT = {
  RESET: [{ target: 'unlit' }],
}

In order to achieve this, we're going to make yet another normalizing function, toArray:

const toArray = value => (Array.isArray(value) ? value : [value])

This simple function ensures that we're always dealing with an array of transitions. Now we need to add it to our stateReducer

// Notice we now need to pass our data into the reducer
const stateReducer = (state, event, data) => {
  const transitionValue = NEXT_STATE_GRAPH[state.current][event.type]

  // There is no transition to make
  if (!transitionValue) return

  const possibleTransitions = toArray(transitionValue).map(toTransitionObject)

  // We're going to use for..of so we can return as
  // early as possible
  for (const transition of possibleTransitions) {
    const { target, cond = () => true } = transition

    // We know we need the `data` for our predicate function
    // We also may want information on the event object as well
    if (cond(data, event)) {
      return target
    }
  }

  // No `cond` succeeded
  return
}

We're not quite done, we need to update our parent reducer function to pass data into our stateReducer function.

const reducer = (state, event) => {
  const eventObj = toEventObject(event)
  const nextData = dataReducer(state.data, eventObj)
  const nextState = stateReducer(state, eventObj, nextData)

  if (!nextState) return state

  return {
    current: nextState,
    data: nextData,
  }
}

We moved the calculation of any new data up in the reducer so that we can use the latest data in our nextState calculation and pass it in to be used by our guards.

We can do one more "optimization" if we'd like. Just like we have a DATA_UPDATERS map, we can make one for our guards as well. Then we can use a string identifier and retrieve our guard. This will make guards reusable in multiple states.

const GUARDS = {
  hasFuse: data => data.hasFuse,
}

//... our break event

const BREAK_EVENT = {
  BREAK: [
    {
      target: 'brokenFuse',
      cond: 'hasFuse',
    },
    'brokenBulb',
  ],
}

//... and in our stateReducer
for (const transition of possibleTransitions) {
  const { target, cond = () => true } = transition
  const condFn = typeof cond === 'string' ? GUARDS[cond] : cond

  if (condFn(data, event)) {
    return target
  }
}

And with that, we've successfully added guards to our useReducer finite state machine. Pretty impressive what we can do with a little effort. You can try out our light bulb with a fuse in the component below.

Light Bulb with a Fuse

{
  "current": "unlit",
  "data": {
    "color": "white",
    "hasFuse": true
  }
}
Events
Change Colors

Summary

Adding guards allows us to conditionally transition states. This enables us to create powerful state machines that handle logic flows for us with the structure of the data, not with a bunch of conditionals in our event handling code. This is one of the key insights of state machines. The structure is the logic, and we don't have to accommodate further for it.

In order to enable guards, we had to do the following:

  • Normalize transition objects
  • Introduce the cond property, a predicate function for conditional transitions
  • Normalize possibleTransitions as an array to simplify our reducer

In the next post in this collection, we'll either try and clean this up a bit so we can make a package of it to pass around our application, or we'll swap out useReducer for useEffectReducer so that we can properly have actions. I haven't decided yet.

Sharing this article on Twitter is a great way to help me out and I really appreciate the support.
+0
Liked the post? Click the beard a few times.
Tags
ReactState Machines
Related Posts:

Let's talk some more about JavaScript, React, and software engineering.

I write a newsletter to share my thoughts and the projects I'm working on. I would love for you to join the conversation. You can unsubscribe at any time.

Data Structures and Algorithms Logo
Data Structures and Algorithms

Check out my courses!

Liked the post? You might like my video courses, too. Click the button to view this course or go to Courses for more information.
Kyle Shevlin's face, which is mostly a beard with eyes
Kyle Shevlin is a front end web developer and software engineer who specializes in JavaScript and React.