loading...
Cover image for Supervising XState Machines with Redux

Supervising XState Machines with Redux

rjdestigter profile image John de Stigter Updated on ・8 min read

The once self-proclaimed Oprah of React has said: "You might not need Redux" and the piano man will let you know the same when using XState. I am here to tell you that you can have your cake and eat it.

I've been doing a lot of xtaste-testing lately. Yes you read that right. It's March 2019 and even #cancelculture is cancelled. I'm cooped up and feeling a little coocoo so I'm pulling out all the puns. This is just another dinner party where we are on an exploration of "How could I do x with XState in ReactJS?"

There seems to be a lot of confusion around using the actor model and sharing state. I'm aware that David Khourshid, the author of XState is working hard on documentation and guidelines and I look forward to it! In the meantime I've come up with a recipe that combines Redux + XState giving me all the delicious single state (and maybe even cheryy-on-top time travel capabilities) of Redux combined with the power of no-more-invalid-states with XState machines.

As the Dutch say: "At the table!"

TLDR;
I created a Redux application that uses only 1 type of reducer to send events to- and extract state from state machines.

For those of you that only eat the frosting, here's the full solution:

and on GitHub here: https://github.com/rjdestigter/xstate-redux-supervisor

Driving Miss Daisy To The Partay

I started the sandbox to experiment with Redux as an event bus capable of accepting events and redirecting them to the correct interpreted state machines.

To to achieve this we need a version of @xstate/react's useMachine that not only interprets the given machine but also informs the Redux store of it's existence so that events can be sent to it. And the send function that normally just dispatches to the interpreted machine's service should also have the capability to send events to other machines.

The Partay

The store's state is an object where each key correlates to a unique machine id and maps to it's service. Since we're working with an event bus I choose to call each registered machine a Station:

import * as X from "xstate";

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

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

The eventQueue is a nice feature we're building allowing us to discard services when components unmount whilst still being able to send events to it's machine. The events are lined up and sent to the machine once it comes back online. Maybe this is just unnecessary extra fondant but we are experimenting here.

As mentioned, the Redux store state will be a mapping of machine id's to a Station object:

/**
 * Map of machine id's to [[Station]]. This is the Redux store state object
 */
type Stations = Record<string, Station>;

Reducing stations with events

We'll need a reducer for Station that can cake, I mean take, an event, send it to the related service if available and return the "next" station, a.ka. the next state:

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

    // Only mutate store state if changes have happened.
    if (next.changed) {
      return {
        ...state,
        state: next
      };
    }

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

The only thing the reducer does is pass event's to an interpreted machine's service or queue the events up if the service is unavailable.

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

AnyEventObject is imported from xstate and describes, you guessed it, any type of event XState can handle. Using TypeScript's Exclude type utility I remove the capability to send events as strings. E.g. no send("CHANGE") only event objects with .type. And I also add a .to option to target stations in the bus. Why did I not just call station's actors?

One reducer to rule them all.

The reducer we described is for each station on the map. Redux needs a final single reducer. This reducer's state will be the map of machine id's to stations and take in events and send them to the correct station in the bus. 2 other features I want to bake in:

  1. Keep the batched events feature. E.g. dispatch an array of events.
  2. Target multiple stations (reducers) with one event.

Redux does not support dispatching an array of events (afaik) so we'll create a special event typed "BATCHED" who's payload is the list of events. And unlike normal Redux, our events are not sent to every reducer in the store but specifically targetted:

/**
 * 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 targetting a specific
 * machine then the event is only sent to that machine.
 * 
 * ToDo: Could be an array of machine id's or an asterisl to target all.
 * 
 * The Redux store reducer.
 * @param state A map of stations
 * @param event Any event.
 */
const stationsReducer = (
  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 stationsReducer(state, { type: "BATCHED", events })
  }

  const station = state[event.to];

  if (station) {
    return {
      ...state,
      [event.to]: stationReducer(state[event.to], event)
    };
  }

  return state;
};

Alrighty, we've got few cakes in the oven here:

if (isBatchedEvent(event))

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

if (isRegisterEvent(event))

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

if (Array.isArray(event.to))

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

Else

We check if there is a station for event.to and if so send the event to it's machine or just return state as is if can't find a machine the event is targetting.

This Store has no TP either

/**
 * The bus where all stations are connected.
 */
const store: Store<Stations, any> = createStore(
  stationsReducer,
  applyMiddleware(logger)
);

const dispatch = (event: DispatchedSupervisedEvent) => store.dispatch(event)

See, I'm using redux-logger here. All* events go through one funnel! You know what means:

Time Travel!

  • ᵉᵛᵉⁿᵗˢ ᵈᶦˢᵖᵃᵗᶜʰᵉᵈ ᵇʸ ᵗʰᵉ ᵐᵃᶜʰᶦⁿᵉ ᶦᵗˢᵉˡᶠ ᵃʳᵉ ⁿᵒᵗ ᶜᵃᵘᵍʰᵗ ᵇʸ ᵒᵘʳ ʳᵉᵈᵘᶜᵉʳˢ. ʸᵉᵗ.

useMachine. But with extra frosting

Our wrapper around useMachine does a whole lot of extra. I'm going to post the entire function and then we'll analyze that slice of pie.

/**
 * 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 maybeStation = store.getState()[id];

  if (!maybeStation) {
    isNew = true;

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

  // 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
      });
    }
  });


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

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

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

  // 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]);


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

  // Voila:
  return [state, sendWrapper, service] as const;
};

Essentialy it does:

  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. Use the original useMachine to get the machine's state 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. Use useEffect to dispatch queued events, if any, to the machine's service
  7. Return the next state, our version of send, and the service just like useMachine would.

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

  • Add .to to an event using the machine's id if it wasn't set so that the reducer targets the right machine.
  • Change string events into event objects with .type
  • 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 either store.dispatch or the send function returned to us by our version of `useMachine_

The last few crumbs

The example in the sandbox. I linked to shows how 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. Feel free to DM me if you have any question or post them in the commments!

Wash your hands. chautelly

Posted on Mar 16 by:

rjdestigter profile

John de Stigter

@rjdestigter

Full Stack FP Lovin' Haskell Exploring React Redux Expert

Discussion

markdown guide