Skip to the code: Cancelling Promises with XState and a comparison with Observables
tl;dr - if you want or need cancellation in side-effecting code that uses promises, you're going to need to roll your own solution.
Ideally with XState you'd want to tie the teardown of a service to a transition, like
{
loading: {
on: { CANCEL: 'idle' },
invoke: { src: "loadData", onDone: "loaded" }
}
}
where moving to the idle
state would naturally tear-down the invoked service.
But that's actually not the case when using Promise-based APIs since by design they don't contain any notion of 'clean up' or 'tear down' logic.
{
services: {
loadData: () => {
/**
* Oops! a memory-leak awaits if this does
* not complete within 2 seconds - eg: if we
* transition to another state
*/
return new Promise((resolve) => {
setTimeout(() => resolve({name: "shane"}), 2000);
})
}
}
}
Solution
If you absolutely must use promises in your application, you'll want to forward a CANCEL
message to your service, and then it can reply with CANCELLED
when it's done running any tear-down logic.
{
id: 'data-fetcher',
initial: 'loading',
strict: true,
context: {
data: undefined,
error: undefined,
},
states: {
loading: {
on: {
/** Allow the running service to see a `CANCEL` message */
CANCEL: { actions: forwardTo('loadDataService') },
CANCELLED: { target: 'idle' }
},
invoke: {
src: 'loadDataService',
onDone: {
target: 'loaded',
actions: ['assignData'],
},
onError: {
target: 'idle',
actions: ['assignError'],
},
},
},
idle: {
on: { LOAD: 'loading' },
},
loaded: {
on: { LOAD: 'loading' },
},
},
}
And now we can just cancel an in-flight setTimeout
call to show how you'd receive that message inside your service.
{
services: {
'loadDataService': () => (send, receive) => {
let int;
// 1: listen for the incoming `CANCEL` event that we forwarded
receive((evt) => {
if (int && evt.type === 'CANCEL') {
// 2: Perform the 'clean up' or 'tear down'
clearTimeout(int);
// 3: Now let the machine know we're finished
send({ type: 'CANCELLED' });
}
});
// Just a fake 3-second delay on a service.
// DO NOT return the promise, or this technique will not work
let p = new Promise((resolve) => {
int = setTimeout(() => {
resolve({ name: 'shane'});
}, 3000);
})
// consume some data, sending it back to signal that
// the service is complete (if not cancelled before)
p.then((d) => send(doneInvoke('loadUserService', d)));
},
},
actions: {
clearAll: assign({ data: undefined, error: undefined }),
assignData: assign({ data: (ctx, evt) => evt.data }),
assignError: assign({ error: (ctx, evt) => evt.data.message }),
},
}
Just use Observables, if you can
Since the Observable interface encapsulates the idea of tearing-down resources, you can simply transition out of the state that invoked the service.
Bonus: the entire machine is just simpler overall too:
export const observableDataMachine = Machine(
{
id: 'data-fetcher',
initial: 'loading',
strict: true,
context: {
data: undefined,
error: undefined,
},
states: {
loading: {
entry: ['clearAll'],
on: {
// this transition alone is enough
CANCEL: 'idle',
},
invoke: {
src: 'loadDataService',
onDone: {
target: 'loaded',
actions: 'assignData',
},
onError: {
target: 'idle',
actions: ['assignError'],
},
},
},
idle: {
on: { LOAD: 'loading' },
},
loaded: {
on: { LOAD: 'loading' },
},
},
},
{
services: {
'loadDataService': () => {
return timer(3000).pipe(mapTo(doneInvoke(SERVICE_NAME, { name: 'shane' })));
},
},
actions: {
clearAll: assign({ data: undefined, error: undefined }),
assignData: assign({ data: (ctx, evt) => evt.data }),
assignError: assign({ error: (ctx, evt) => evt.data.message }),
},
},
);
Top comments (0)