Matt Pocock
Posted on May 13, 2021
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
};
},
},
});
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();
};
},
},
},
},
});
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);
};
},
},
},
);
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();
};
},
},
});
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.
Posted on May 13, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.