Cave Troll: Cracking React, XState & PusherJS Communication

headwinds

brandon flowers

Posted on May 24, 2022

Cave Troll: Cracking React, XState & PusherJS Communication

As we develop an app, we enjoy a sense of flow, connecting functions following our mental models of how we believe the code should all fit together. It can be frustrating to get bogged down by functions that don't behave as we expect them to; especially when we adopt some new library that has promised us to save us time and tears.

I've been experimenting with the mechanics that drive modern 2D multiplayer experiences that feature machine learning taking inspiration from Wildermyth.

I'm building with React, PusherJS, XState, RabbitMQ, and a recommendation engine built in Python. The goal is have them all work in harmony to provide a non-blocking experience where long jobs like reinforcement learning tasks are processed in the background and the user can perform other activities and receive feedback on their progress.

In this post, I'm only going to going share how React can subscribe to PusherJS events via XState, and update the UI once the work has completed. I've been a big fan of XState's visualization tool using it Storybook for the past year and recently started using the new Stately tools for VS Code as well as kicking the tires of their hosted editor.

I won't go into the backend of this project, but it's important to say a few things so that you can understand why I wanted to use PusherJS. So I will mention that the setup is a pipeline with 4 flask apps: API, producer, consumer, and a recommendation model. I wanted a pipeline flow so that I can add and remove libraries to experiment and transform the data. Depending on what I want to play with, I can try different models and other tasks like text processing.

With this backend in place, I have developed the frontend app to make Restful requests and subscribe to PusherJS which will eventually receive an update.

Since I'm building a 2D game, the app will make requests to generate maps and predict optimized walking paths but the UI will not be blocked because the final response will be picked up from a PusherJS subscription.

Now how do we get PusherJS to update our XState machine within React? But first, why XState as my state management weapon of choice?

Similar to what attracted me to functional programming, I liked that XState is deterministic which basically means that I should know where I am, where I came from, and where I'm going in the code base at all times. It also comes with a visual editor, and finally provides clear examples of how to reduce complexity and split up machines making it more readable and testable parts; props to Steve Ruizok and his multiplayer XState experiments.

I wasn't satisfied with my first attempts to make this happen. I tried several approaches to position the machine inside and outside the React functional component. I could trigger the task and have my pipeline return the result to a bound instance of PusherJS within React.

The PusherJS subscription would then send an XState event with the result as a payload and my context should be updated but unfortunately it failed to update, and thus my view wouldn't react to the change.

So I turned to google to see if anyone else was having this issue, and discovered a few interesting posts including these two: How should a machine interact with websocket? and XState Websocket Machine.

It's a bit disappointing to see that Github thread locked but it basically inspired this article so please use the comments below if you have any questions; not to say I'll have all the answers; but we can certainly struggle together.

I'll share the section of my state machine that I borrowed from glenndixon's post and show how I worked through this communication.

export const caveTrollMachine = createMachine(
  {
    context: initialContext,
    invoke: {
      id: 'socket',
      src: (context, event) => (callback, onEvent) => {
        const { channel } = context;

        setTimeout(function () {
          console.log('got here after 1 sec ');
          callback({ type: 'FETCH_USER_MAP' });
        }, 1000);

        const onGetMapSuccessHandler = data => {

          const {
            result: { map },
          } = data;

          callback({ type: 'PUSHER_FOUND_MAP_SUCCESS', generatedMap: map });
        };

        PusherService.bindToPusher(channel, c.GET_MAP_SUCCESS, onGetMapSuccessHandler);
      },
    },
    id: 'caveTroll',
    initial: 'idle',
    states: {
      idle: {
        on: {
          FETCH_USER_MAP: {
            target: 'loadingUserMap',
          },
          SAY_HI: {
            target: 'sayingHi',
          },
        },
      },
Enter fullscreen mode Exit fullscreen mode

I also installed the XState Chrome extension so that I could use the machine visualization via Chrome dev tools but found it not as effective as the hosted Stately visualizer. In fact, the many times the chrome extension would fail to render and all I saw was "Resetting..." in the window.

xstate viz

Once I could watch the machine, I could finally see the actual state, and was able to add the missing on listener to that state so that it would hear when PusherJS had updated with the result of my request.

I left my SAY_HI event in that screenshot to show how I start by building simple experiments. It's always nice to follow hello world first approach to programming where I simply want to get some text to appear and then add more complexity once I understand how it works.

on: {
  FETCH_MAP_SUCCESS: {
   target: 'idle',
   actions: assign({ 
      tileModels: onTileModelsUpdateHandler, 
      generatedMap: onGeneratedMapHandler, 
      isMapGenerated: true }),
   },
},
Enter fullscreen mode Exit fullscreen mode

Everything was working in my state chart as expected except my onTileModelsUpdateHandler. For some reason, it appeared that when this function fired, it did update the models with the proper x,y coordinates from the DOM but then suddenly the entire array would be empty.

  useEffect(() => {
    if (tileModels.length > 0) {
      const el = document.getElementById(`tile8`);
      if (el && tileModels?.length > 0) {
        const updatedTileModels = tileModels.map(model => {
          const newEl = document.getElementById(`tile${model.id}`);
          const boundingRect = newEl.getBoundingClientRect();
          return { ...model, x: boundingRect.x, y: boundingRect.y };
        });
        send({ type: 'UPDATE_TILE_MODELS', updatedTileModels });
      }
    }
  }, [tileModels]);
Enter fullscreen mode Exit fullscreen mode

If I ever get stuck on a coding problem, the first thing I do is start to remove the complexity, and break it down into a more simple task . I'll never forget when I was pair-programming with another developer and she asked, "What's the delta?".

So I started to refactor, rewrite, and remove some of the deltas aka the differences and complexity. Instead of drawing a grid, I simplified it to one row. I changed my design from a field to single tunnel, a cave.

With the cave model, I didn't need the grid function, and once I replaced it with a function that drew a row everything started to work again!

By eliminating the grid, I was able to identify it and not the XState machine as the source of my vanishing model problem.

  const createGrid = () => {
    const grid = [];
    // credit https://stackoverflow.com/questions/22464605/convert-a-1d-array-to-2d-array
    // but be careful here since splice is destructive - make sure you don't destroy the original array!!!
    // important lesson for xstate that you don't mutate the original array!!!
    const cloneModels = [...models];
    // vs using just models
    while (cloneModels.length) grid.push(cloneModels.splice(0, totalInRow));
    return grid;
  };
Enter fullscreen mode Exit fullscreen mode

I didn't realize that I was mutating the original array through the destructive act of using splice. Of course this is one of the dangers of copypasta from Stackoverflow, and I did review the source before implementing it, and it appeared harmless. I've since added ImmutableJS into the mix which I know is a bit controversial because it's not maintained but that doesn't mean it doesn't work. I have another large library of game asset datasets built on top of it and have never had issue with it.

After cloning the array, I was then able to bring back the grid, and once again everything worked as expected. I had assumed the context within the machine was immutable. I want to explore using XState's guards in the future to see if I could have received some feedback and data protection which could have helped point to my grid function instead of having to tear up my machine work digging for the cause.

By refactoring the machine, it did help me understand much more about how it works and definitely improved my work flow and ability to detect and squash bugs.

Frodo and the cave troll

💖 💪 🙅 🚩
headwinds
brandon flowers

Posted on May 24, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related