DEV Community

Cover image for State machine advent: Reusable conditional logic with custom guards (17/24)
Mikey Stengel
Mikey Stengel

Posted on • Updated on

State machine advent: Reusable conditional logic with custom guards (17/24)

Just yesterday, we learned about the second argument of the XState Machine factory function to explicitly define guards and actions. Today, we want to take our conditional logic to the next level by passing arguments to a guard.

What if instead of two states (warm | cold), our thermostat machine needs to determine whether the temperature is freezing | cold | warm | hot. We could totally create a distinct guard for each scenario.

// event determines the next state based on the first guard that evaluates to true
SET_TEMPERATURE: [
  {
    target: '.freezing',
    cond: 'isTemperatureFreezing',
    actions: 'assignTemperature',
  },
  {
    target: '.cold',
    cond: 'isTemperatureCold',
    actions: 'assignTemperature',
  },
  {
    target: '.warm',
    cond: 'isTemperatureWarm',
    actions: 'assignTemperature',
  },
  {
    target: '.hot',
    actions: 'assignTemperature',
  },
]

Then we define the guards inside the configuration object

{
  guards: {
    isTemperatureFreezing: (context, event) => event.temperature < 0, 
    isTemperatureCold: (context, event) => event.temperature < 18, 
    isTemperatureWarm: (context, event) => event.temperature < 30,
  },
}

This works great but if we want to make our code even nicer, we can define a single guard to which we can pass arguments. The cond keyword also accepts an object to which we can pass arguments. To reference our custom guard, the same API as for events is used: The name is specified as a string value of the type property.

// event determines the next state based on the first guard that evaluates to true
SET_TEMPERATURE: [
  {
    target: '.freezing',
    cond: {
      type: 'isTemperatureBelow',
      temperatureThreshold: 0, 
    },
    actions: 'assignTemperature',
  },
  {
    target: '.cold',
    cond: {
      type: 'isTemperatureBelow',
      temperatureThreshold: 18, 
    },
    actions: 'assignTemperature',
  },
  {
    target: '.warm',
    cond: {
      type: 'isTemperatureBelow',
      temperatureThreshold: 30, 
    },
    actions: 'assignTemperature',
  },
  {
    target: '.hot',
    actions: 'assignTemperature',
  },
]


// then we define a single custom guard that can access the temperatureThreshold variable to perform our conditional logic.
{
  guards: {
    isTemperatureBelow: (context, event, stateGuard) => event.temperature < stateGuard.cond.temperatureThreshold  
  }
}

Guards are invoked with one more argument than actions. Besides the context and event, the third argument of a guard holds the current state of the machine as well as the whole cond object. All the variables we pass to our custom guard can be read within this object as seen in the example above.

Even though guards are really powerful, do not be tempted to abuse their power by performing side effects in them just because you can access the current state of your machine. They should always be pure functions meaning they take in some input and always return a boolean without performing any mutations, sending requests, etc.

Last but not least, let's put our custom guard into our thermostat machine so that we can visualize it properly.

import { Machine, assign } = 'xstate';

const thermostatMachine = Machine(
  {
    id: 'thermostat',
    initial: 'inactive',
    context: {
      temperature: 20,
    },
    states: {
      inactive: {
        on: {
          POWER_TOGGLE: 'active'
        }
      },
      active: {
        initial: 'warm',
        states: {
          freezing: {},
          cold: {},
          warm: {},
          hot: {},
        },
        on: {
          POWER_TOGGLE: {
            target: 'inactive',
          },
          SET_TEMPERATURE: [
            {
              target: '.freezing',
              cond: {
                type: 'isTemperatureBelow',
                temperatureThreshold: 0, 
              },
              actions: 'assignTemperature',
            },
            {
              target: '.cold',
              cond: {
                type: 'isTemperatureBelow',
                temperatureThreshold: 18, 
              },
              actions: 'assignTemperature',
            },
            {
              target: '.warm',
              cond: {
                type: 'isTemperatureBelow',
                temperatureThreshold: 30, 
              },
              actions: 'assignTemperature',
            },
            {
              target: '.hot',
              actions: 'assignTemperature',
            },
          ]
        }
      },
    }
  },
  /**
   * Configuration object
   */
  {
    actions: {
      assignTemperature: assign({
        temperature: (context, event) => event.temperature,
      }),
    },
    guards: {
      isTemperatureBelow: (context, event, stateGuard) => event.temperature < stateGuard.cond.temperatureThreshold  
    }
  }
);

Sweet! Our conditional logic is now very reusable. In case we want to add some more temperature states to our thermostat machine, we can simply call the custom guard with a different value. Tomorrow, we'll take a look at how to define actions outside of our machine which will open a realm of possibilities.

About this series

Throughout the first 24 days of December, I'll publish a small blog post each day teaching you about the ins and outs of state machines and statecharts.

The first couple of days will be spent on the fundamentals before we'll progress to more advanced concepts.

Discussion (2)

Collapse
shoocky profile image
Nemanja Radosavljevic

instead of adding assign action 'assignTemperature' to every transition, can we just add one transition with no target and no guard with just assignTemperature action?

Collapse
codingdive profile image
Mikey Stengel Author

That would also work, sure.