DEV Community

Mikey Stengel
Mikey Stengel

Posted on • Updated on

State machine advent: A better way to type guards and actions (16/24)

Using conditional logic with guards often means that we have to define the same action twice. This is exactly what we had to do yesterday:

SET_TEMPERATURE: [
  {
    target: '.cold',
    cond: (context, event) => event.temperature < 18,
    actions: assign({
      temperature: (context, event) => event.temperature,
    }),
  },
  {
    target: '.warm',
    actions: assign({
      temperature: (context, event) => event.temperature,
    }),
  },
]

Today, we want to refactor our code so that we don't have to repeat ourselves. Instead of defining the very same action (of assigning the temperature) twice, we can give the action a name, define it in a separate configuration object and then call the action by its name within the machine definition.

import { Machine, assign } = 'xstate';

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

The configuration object is the second argument of the Machine factory function. We are now using the named action - assignTemperature - to update the context. Since we can use this named action throughout our machine definition, we do not have to define the action twice.
Likewise, we apply the very same concept to guards. As a result, we can use an expressive name for the conditional logic of our machine (isTemperatureCold).

Moving actions and guards into the configuration object does not only make our code more DRY and readable, it also improves how well our machine can be serialized and will show the distinct action/guard names within the visualization.

visualization before

visualization after


A couple of days ago, I wrote how I dislike string variables within my machines as it makes the code harder to refactor and additionally, easier to introduce bugs by mistyping. If you like enums as much as I do, you can totally get rid of all the strings by defining your named actions and guards with string enums. 🎉

import { Machine, assign } = 'xstate';

enum THERMOSTAT {
  'INACTIVE' = 'inactive',
  'ACTIVE' = 'active',
  'COLD' = 'cold',
  'WARM' = 'warm',
}

enum THERMOSTAT_EVENT {
  'POWER_TOGGLE' = 'POWER_TOGGLE',
  'SET_TEMPERATURE' = 'SET_TEMPERATURE',
}

enum THERMOSTAT_GUARD {
  'IS_TEMPERATURE_COLD' = 'isTemperatureCold',
}

enum THERMOSTAT_ACTION {
  'ASSIGN_TEMPERATURE' = 'assignTemperature',
}


const thermostatMachine = Machine(
  {
    id: 'thermostat',
    initial: THERMOSTAT.INACTIVE,
    context: {
      temperature: 20,
    },
    states: {
      [THERMOSTAT.INACTIVE]: {
        on: {
          [THERMOSTAT_EVENT.POWER_TOGGLE]: THERMOSTAT.ACTIVE,
        }
      },
      [THERMOSTAT.ACTIVE]: {
        initial: THERMOSTAT.WARM,
        states: {
          [THERMOSTAT.COLD]: {},
          [THERMOSTAT.WARM]: {},
        },
        on: {
          [THERMOSTAT_EVENT.POWER_TOGGLE]: {
            target: THERMOSTAT.INACTIVE,
          },
          [THERMOSTAT_EVENT.SET_TEMPERATURE]: [
            {
              target: THERMOSTAT.COLD,
              cond: THERMOSTAT_GUARD.IS_TEMPERATURE_COLD,
              actions: THERMOSTAT_ACTION.ASSIGN_TEMPERATURE,
            },
            {
              target: THERMOSTAT.WARM,
              actions: THERMOSTAT_ACTION.ASSIGN_TEMPERATURE,
            },
          ]
        }
      },
    }
  },
  /**
   * Configuration object
   */
  {
    actions: {
      [THERMOSTAT_ACTION.ASSIGN_TEMPERATURE]: assign({
        temperature: (context, event) => event.temperature,
      }),
    },
    guards: {
      [THERMOSTAT_GUARD.IS_TEMPERATURE_COLD]: (context, event) => event.temperature < 18,
    },
  }
);

Again, using enums and the configuration object is optional but I do highly recommend you to use both extensively.

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 (0)