XState: Why I Love Invoked Callbacks

mattpocockuk

Matt Pocock

Posted on May 13, 2021

XState: Why I Love Invoked Callbacks

XState offers several primitives for representing long-running application processes. These are usually expressed as services. I've written a bit about services here - but today I wanted to talk about my favourite way of expressing services: the Invoked Callback.

The Invoked Callback combines immense flexibility with good readability and a solid Typescript experience. They look like this:

createMachine({
  invoke: {
    src: (context, event) => (send, onReceive) => {
      // Run any code you like inside here

      return () => {
        // Any code inside here will be called when
        // you leave this state, or the machine is stopped
      };
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Let's break this down. You get access to context and event, just like promise-based services. But send is where things really get interesting. Let's break down what makes send useful with an example.

File Uploads

Imagine you need to build a file uploader, and you have a handy function called startUpload that uploads some data, and exposes an onProgressUpdate parameter to update the progress.

createMachine({
  context: {
    progress: 0,
  },
  initial: 'idle',
  states: {
    idle: {
      on: {
        START: 'pending',
      },
    },
    pending: {
      on: {
        PROGRESS_UPDATED: {
          assign: assign({
            progress: (context, event) => event.progress,
          }),
        },
        CANCEL: {
          target: 'idle',
        },
      },
      invoke: {
        src: (context) => (send) => {
          const uploader = startUpload({
            onProgressUpdate: (progress) => {
              send({
                type: 'PROGRESS_UPDATED',
                progress,
              });
            },
          });

          return () => {
            uploader.cancel();
          };
        },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

This machine starts in the idle state, but on the START event begins its invoked service, which uploads the file. It then listens for PROGRESS_UPDATED events, and updates the context based on its updates.

The CANCEL event will trigger the uploader.cancel() function, which gets called when the state is left. React users may recognise this syntax - it's the same as the cleanup function in the useEffect hook.

Note how simple and idiomatic it is to cancel the uploader - just exit the state, and the service gets cleaned up.

Event Listeners

The invoked callback's cleanup function makes it very useful for event listeners, for instance window.addEventListener(). XState Catalogue's Tab Focus Machine is a perfect example of this - copied here for ease:

createMachine(
  {
    initial: 'userIsOnTab',
    states: {
      userIsOnTab: {
        invoke: {
          src: 'checkForDocumentBlur',
        },
        on: {
          REPORT_TAB_BLUR: 'userIsNotOnTab',
        },
      },
      userIsNotOnTab: {
        invoke: {
          src: 'checkForDocumentFocus',
        },
        on: {
          REPORT_TAB_FOCUS: 'userIsOnTab',
        },
      },
    },
  },
  {
    services: {
      checkForDocumentBlur: () => (send) => {
        const listener = () => {
          send('REPORT_TAB_BLUR');
        };

        window.addEventListener('blur', listener);

        return () => {
          window.removeEventListener('blur', listener);
        };
      },
      checkForDocumentFocus: () => (send) => {
        const listener = () => {
          send('REPORT_TAB_FOCUS');
        };

        window.addEventListener('focus', listener);

        return () => {
          window.removeEventListener('focus', listener);
        };
      },
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

When in the userIsOnTab state, we listen for the window's blur event. When that happens, and REPORT_TAB_BLUR is fired, we clean up the event listener and head right on over to userIsNotOnTab, where we fire up the other service.

Websockets

Invoked callbacks can also receive events via the onReceive function. This is perfect when you need to communicate to your service, such as sending events to websockets.

import { createMachine, forwardTo } from 'xstate';

createMachine({
  on: {
    SEND: {
      actions: forwardTo('websocket'),
    },
  },
  invoke: {
    id: 'websocket',
    src: () => (send, onReceive) => {
      const websocket = connectWebsocket();

      onReceive((event) => {
        if (event.type === 'SEND') {
          websocket.send(event.message);
        }
      });

      return () => {
        websocket.disconnect();
      };
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

In order to receive events, services need an id. Not all events are forwarded to the invoked service, only those which we select via the forwardTo action.

Here, we can connect to the websocket, establish two-way communication, and clean it up all in a few lines of code.

My Love Letter

Invoked callbacks are a concise, flexible method of invoking services in XState. There isn't a case they can't cover - and they're one of my favourite parts of the XState API.

💖 💪 🙅 🚩
mattpocockuk
Matt Pocock

Posted on May 13, 2021

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

Sign up to receive the latest update from our blog.

Related