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.
Top comments (8)
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
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.
See the 'Event Listeners' example
My bad, sorry ๐ thanks Matt!
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!
Thanks for posting!
I'm on the Invoked Callbacks team too!! love them! (team coined by the great @erikras ๐)
Would you favor using a callback over invoking a promise?