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)