DEV Community

Shane Osbourne
Shane Osbourne

Posted on

Cancel a Promise when using XState

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" } } } 
Enter fullscreen mode Exit fullscreen mode

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); }) } } } 
Enter fullscreen mode Exit fullscreen mode

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' }, }, }, } 
Enter fullscreen mode Exit fullscreen mode

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 }), }, } 
Enter fullscreen mode Exit fullscreen mode

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 }), }, }, ); 
Enter fullscreen mode Exit fullscreen mode

Top comments (0)