DEV Community

Mikey Stengel
Mikey Stengel

Posted on

State machine advent: The power of null events (20/24)

Events are what drive our state changes and are typically sent to a state machine by components. There is one event that is different and we have yet to cover. The null event:

someStateNode: {
  on: {
    // This is a null event.
    '': {
      target: 'someOtherStateNode',
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

It doesn't have a type and is an internal event, meaning no component on the outside can send it to the machine. To elaborate, it is immediately executed once a state node is entered and instantly transitions our state machine to a new state. In the example above, as soon as someStateNode is entered, the machine will transition to someOtherStateNode.

This doesn't seem particularly useful on its own so let's see how this translates to a real-world example.

If we look at a simplified thermostat machine from a few days ago, we can see that we've defined an initial temperature of 20°C and set the initial state to warm. This is correct since the SET_TEMPERATURE event would also transition our state machine to the warm state as soon as the temperature hits at least 18°C.

import { Machine, assign } = 'xstate';

const thermostatMachine = Machine({
  id: 'thermostat',
  initial: 'warm',
  context: {
    temperature: 20,
  },
  states: {
    cold: {},
    warm: {},
  },
  on: {
    SET_TEMPERATURE: [
      {
        target: '.cold',
        cond: (context, event) => event.temperature < 18,
        actions: assign({
          temperature: (context, event) => event.temperature,
        }),
      },
      {
        // transition without a guard as a fallback.
        target: '.warm',
        actions: assign({
          temperature: (context, event) => event.temperature,
        }),
      },
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

A problem occurs if we lower the initial temperature but forget to change the initial state, or when we don't even control the initial temperature of the machine. You read that right. Similar to how a component can pass a configuration object to define actions and guards, components can also set the initial context of our machine.

import React from 'react';
import { useMachine } from 'xstate';
import {thermostatMachine} from './thermostatMachine';

const ColdRoom = () => {
  const [state, send] = useMachine(thermostatMachine.withContext({ temperature: 5 }));

  console.log(state);
  // state.context === 5;
  // state.matches('warm') === true (OH NO)
  return null;
}
Enter fullscreen mode Exit fullscreen mode

When calling thermostatMachine.withContext, it merges and overwrites the context we have defined in our machine with the values passed to the function. Therefore, the initial temperature is no longer 20°C as specified in our machine definition, but 5°C. Despite the low initial temperature, our thermostat still thinks it's warm since the SET_TEMPERATURE event was never called to perform the conditional state transition that would rightfully put the machine into the cold state.

What I like to do to fix those kinds of problems is to add another state node called initializing. It should use a null event and multiple conditional transitions to set the correct initial state of the machine.

import { Machine, assign } = 'xstate';

const thermostatMachine = Machine({
  id: 'thermostat',
  initial: 'initializing',
  context: {
    temperature: 20,
  },
  states: {
    initializing: {
      on: {
        '':
          [
            {
              target: 'cold',
              cond: (context) => context.temperature < 18,
            },
            {
              // transition without a guard as a fallback.
              target: 'warm',
            },
          ],
      }
    },
    cold: {},
    warm: {},
  },
  on: {
    SET_TEMPERATURE: [
      {
        target: '.cold',
        cond: (context, event) => event.temperature < 18,
        actions: assign({
          temperature: (context, event) => event.temperature,
        }),
      },
      {
        // transition without a guard as a fallback.
        target: '.warm',
        actions: assign({
          temperature: (context, event) => event.temperature,
        }),
      },
    ],
  },
});
Enter fullscreen mode Exit fullscreen mode

We have now ensured that our thermostat will always be in the correct state by setting initializing as the initial state which then instantly transitions our machine to warm or cold depending on the current context. Because the initializing state immediately transitions into another state, it is also known as a transient transition.

When defining null events, ensure to always work with the context as the event itself does not contain any data. If you were to log the event of the null event, it would just print: { type: '' }.

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.

Top comments (0)