DEV Community

Cover image for XState: Why I Love Invoked Callbacks
Matt Pocock
Matt Pocock

Posted on • Edited on • Originally published at stately.ai

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.

Top comments (8)

Collapse
 
noriste profile image
Stefano Magni

Hey Matt, replacing ALL the services with invoked callbacks (even a simple fetch that returns a promise) has only one "weak" point: testing. If I'm correct, mocking services is handy with withConfig, while for callbacks I must mock the imported and consumed module, right?
So told: I know that callbacks have superior Typescript experience and it's all about the trade-offs ๐Ÿ˜Š
And thanks, I love the clarity of your articles

Collapse
 
mattpocockuk profile image
Matt Pocock

You can mock invoked callbacks in exactly the same way as with services! Use a named service and then mock it in the same way.

Collapse
 
mattpocockuk profile image
Matt Pocock

See the 'Event Listeners' example

Thread Thread
 
noriste profile image
Stefano Magni

My bad, sorry ๐Ÿ™ thanks Matt!

Collapse
 
trainingmontage profile image
Jeff Caldwell

This is a really helpful pattern that's about to help me out a whole lot with a personal project. Thanks so much for sharing it!

Collapse
 
arunmurugan78 profile image
Arun Murugan

Thanks for posting!

Collapse
 
horacioh profile image
Horacio Herrera

I'm on the Invoked Callbacks team too!! love them! (team coined by the great @erikras ๐Ÿ˜‰)

Collapse
 
eightarmcode profile image
Kat Chilton

Would you favor using a callback over invoking a promise?