DEV Community

John for Jobber

Posted on • Edited on

Supervising XState Machines with Redux

Redux introduced me to the concept of a single state store for managing a front-end application's state. It was a fantastic library to help maintain large and complex state in an app built with ReactJS. Combined with redux-saga for managing network requests and other side-effects it made a pretty powerful tool. However as soon I saw XState in action I dropped all my "religious" convictions about a single state store and was sold on the idea of statecharts.

In this post we are going to have our cake and eat it and use both Redux and XState. State machines lend themselves well for implementing the actor model and I thought Redux would be a good candidate to supervise messaging between running machines and rehydrating their state.

This article is a rewrite of a previous post that I wrote on the same topic.

The Store

Our Redux store state is an object where each key is a unique machine identifier and maps to a service. (In XState services are interpreted state machine configurations that handle incoming events and state transitions)

import * as X from "xstate";

type AnyService = X.Interpreter<any, any, any>;
type AnyMachine = X.StateMachine<any, any, any>;

/**
 * A machine is a machine attached to the event bus.
 */
type StoredMachine = {
  /** Unique identifier of the machine. Must be unique for the whole app. */
  id: string;
  /** A state machine. */
  machine: AnyMachine;
  /** A running service that has interpreted a state machine configuration.
   * Only available if the component needing it is live / renderered.
   * */
  service?: AnyService;
  /**
   * If a machine is not live but components are sending events to it
   * they will be parked here.
   **/
  eventQueue: AnyDispatchedEvent[];
  /** The most recent state of a machine live or not. */
  state: X.State<any, any, any, any>;
};

/**
 * Map of machine id's to [[StoredMachine]]. This is the Redux store state object
 */
type StoreState = Record<string, StoredMachine>;
Enter fullscreen mode Exit fullscreen mode

The eventQueue is a nice feature we are including that will allow us to discard services when components unmount while still being able to send events to the machine. The events are queued up and sent to the machine once it comes back online.

Reducing machines and events

We'll need a reducer for StoredMachine that:

  1. Can take an event
  2. Send it to the related service if its running
  3. and return the state of the machine

As mentioned, our Redux store will have the same reducer for each state entry since all our state entries are of the same type:

type AnyDispatchedEvent = Exclude<X.AnyEventObject, string> & { to: string | string[] }

/**
 * The only type of reducer you'll see here.
 * 
 * @param state A machine
 * @param event An event targetting this machine.
 */
const machineReducer = (state: Station, event: AnyDispatchedEvent): StoredMachine => {
  if (state.service) {
    // If the machine's service is live, fire away and compute it's next state.
    const next = state.service.send(event);

    // Only change the store if changes have occurred.
    if (next.changed) {
      return {
        ...state,
        state: next
      };
    }

    return state;
  } 

  // Queue the event. Event will be dispatched as soon as the machine is live.
  return {
    ...state,
    eventQueue: [...state.eventQueue, event]
  };
};
Enter fullscreen mode Exit fullscreen mode

The only thing this reducer does is dispatch events to an interpreted machine's service or queue the events up if the service is offline (because no components are using the many anymore).

One reducer to rule them all.

The reducer we created is for each individual machine in the store. Redux needs a final single reducer. This reducer's state will be the map of machine id's and stored machines and process events and send them to the correct machine in the store.

2 other features I would like us to include:

  • A batched events feature. Being able to dispatch an array of events.
  • Target multiple machines (reducers) with one event.

Redux does not support dispatching an array of events so we'll create a special event typed "BATCHED" of which the payload is an array of events. Unlike regular Redux reducers our events are not sent to every reducer available to the store but specifically target individual reducers:

/**
 * Special events are:
 * 
 * `.type: "BATCHED"` - Handles batched event just like service.send;
 * `.type: "REGISTER_MACHINE" - Introduces a new machine to the bus;
 * 
 * For all other events, if a `.to` property is present targeting a specific
 * machine then the event is only sent to that machine.
 * 
 * ToDo: Could be an array of machine id's or an asterisk to target all.
 * 
 * The Redux store reducer.
 * @param state A map of machines
 * @param event Any event.
 */
const machinesReducer = (
  state: Stations = {},
  event: DispatchedSupervisedEvent
): Stations => {
  if (isBatchedEvent(event)) {
    return event.events.reduce((acc, next) => {
      return stationsReducer(acc, next);
    }, state);
  } else if (isRegisterEvent(event)) {
    return {
      ...state,
      [event.payload.id]: event.payload
    };
  } else if (Array.isArray(event.to)) {
    const events = event.to.map(id => {
      return {
        ...event,
        to: id,
      }
    })

    return machinesReducer(state, { type: "BATCHED", events })
  }

  const machine = state[event.to];

  if (machine) {
    return {
      ...state,
      [event.to]: machineReducer(state[event.to], event)
    };
  }

  return state;
};
Enter fullscreen mode Exit fullscreen mode

Let's review each branch of the if statement here.

if (isBatchedEvent(event))

If the dispatched event's type property equals "BATCHED" then event.events will be an array of events to be sent to the machines. We just call machinesRedcuer recursively for each event and build up the next state using Array.reduce

if (isRegisterEvent(event))

This is the special event for registering new machines with the store. event.payload should be of type StoredMachine

if (Array.isArray(event.to))

This handles the case where a single event is sent to multiple machines. It transforms the single event into a list of batched event and calls machinesReducer with the special "BATCHED" event.

else

We check if there is a machine given event.to and if so send the event to that machine or return state as is if we can't find a machine the event is targeting.

Connecting with ReactJS

Now that we have the Redux part down we can move onto ReactJS. Our wrapper around XState's useMachine does a whole lot of extra. I'm going to post the entire function before analyzing it in parts.

/**
 * Wrapper around _useMachine_
 * 
 * Machines are registered with the [[store]]. Events
 * are sent to the store rather than the _send_ function
 * returned by _useMachine_.
 * 
 * @param machine
 */
const useSupervisedMachine = <
  TContext,
  TEvent extends X.EventObject = X.AnyEventObject,
  TTypestate extends X.Typestate<TContext> = any
>(
  machine: X.StateMachine<TContext, any, TEvent, TTypestate>
) => {
  // Unique identifier
  const id = machine.id;

  let isNew = false;
  let maybeStoredMachine = store.getState()[id];

  // 1.
  if (!maybeStation) {
    isNew = true;

    maybeStation = {
      id: id,
      machine,
      eventQueue: [],
      state: machine.initialState
    };
  }

  // 2.
  // Effect with no dependencies. Actual effect is only
  // run once and registers the machine with the store.
  React.useEffect(() => {
    if (isNew) {
      dispatch({
        type: "REGISTER_MACHINE",
        // @ts-ignore
        payload: maybeStation
      });
    }
  });

  // 3.
  const [   state, , service] = useMachine(machine, { state: maybeStation.state} );

  // 4.
  // Mutate the service property on the machine's related [[StoredMachine]]
  React.useEffect(() => {
    store.getState()[id].service = service;

    return () => {
      store.getState()[id].service = undefined;
    };
  }, [service, id]);

  // 5.
  // Our verison of service.send
  const sendWrapper = React.useMemo(() => {
    const sendToStore = (
      event: SupervisedEvent<TEvent>,
      payload?: X.EventData | undefined
    ): void => {
      // `.to` is added too all events and populated
      // with the machine's id if not present.
      if (Array.isArray(event)) {
        // Redux doesn't like array's of events so this will do:

        const events: AnyDispatchedEvent[] = event.map(batchedEvent => {
          const dispatchableEvent: AnyDispatchedEvent = typeof batchedEvent === 'string' ? {
            type: batchedEvent,
            to: id
          } : {
            ...batchedEvent,
            type: batchedEvent.type,
            to: batchedEvent.to || id
          }

          return dispatchableEvent
        })

        const batchedEvent = {
          type: "BATCHED" as const,
          events,
        }

        dispatch(batchedEvent as any);

        return;
      }

      // If not batched than just a single plain event.
      const dispatchableEvent: AnyDispatchedEvent = typeof event === 'string' ? {
        type: event,
        to: id
      } : {
        ...event,
        type: event.type,
        to: event.to || id
      }

      dispatch(dispatchableEvent);
    };

    return sendToStore;
  }, [id]);

  // 6.
  React.useEffect(
    () => {
      if (maybeStation.eventQueue.length > 0) {
        const queue = maybeStation.eventQueue.splice(0, maybeStation.eventQueue.length)
        dispatch({ type: 'BATCHED', events: queue})
      }
    }
  )

  // 7., voila:
  return [state, sendWrapper, service] as const;
};
Enter fullscreen mode Exit fullscreen mode

1.

Create a station object for a machine + id if it doesn't exist yet in the store.

2.

Use useEffect to dispatch the special registration event if the station is new.

3.

Call XState's version of useMachine to providing us with the machine's state, dispatcher, and service.

4.

Use useEffect to mutate the store's state with the machine's now available service. The effect removes the service when the component unmounts.

5.

Create a version of send that the store can handle.

6.

Dispatch queued events, if any, to the machine's service

7.

Return the next state, our version of the dispatcher, and the service just like useMachine would.

sendWrapper looks like a lot of code but all it does is:

  1. Add .to to an event object using the machine's id if it wasn't set so that the reducer targets the right machine.
  2. Change string events into event objects with a type property. send("TOGGLE") becomes send({type: "TOGGLE"}).
  3. Convert lists of batched events into a single special "BATCHED" event the store can consume.

And that's it! We now have an architecture in place for sharing state between machines in different ReactJS components as well as being able to dispatch events to any machine using eitherstore.dispatch or the send function returned to us by our version of useMachine

codesandbox

In the above sandbox it demonstrates that when a user changes the country it clears the city input by dispatching 2 events.

I'm also using the useSelector hook provided by react-redux to read state and give me the value of the Country input. That's right! You can keep using all your favourite Redux tools.

I hope you feel inspired. I'm really excited to see where XState is going and what patterns and strategies we'll all come up with. There are flaws in this setup but it was fun to experiment.

About Jobber

At Jobber I am exploring XState to help us manage front-end state in our apps. We are hiring for remote positions across Canada at all software engineering levels!

Our awesome Jobber technology teams span across Payments, Infrastructure, AI/ML, Business Workflows & Communications. We work on cutting edge & modern tech stacks using React, React Native, Ruby on Rails, & GraphQL.

If you want to be a part of a collaborative work culture, help small home service businesses scale and create a positive impact on our communities, then visit our careers site to learn more!

Top comments (0)