DEV Community

Cover image for How to Annul Promises in JavaScript
Zachary Lee
Zachary Lee

Posted on • Originally published at webdeveloper.beehiiv.com on

How to Annul Promises in JavaScript

In JavaScript, you might already know how to cancel a request: you can use xhr.abort() for XHR and signal for fetch. But how do you cancel a regular Promise?

Currently, JavaScript's Promise does not natively provide an API to cancel a regular Promise. So, what we’ll discuss next is how to discard/ignore the result of a Promise.

Method 1: Using the New Promise.withResolvers()

A new API that can now be used is Promise.withResolvers(). It returns an object containing a new Promise object and two functions to resolve or reject it.

Here’s how the code looks:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});
Enter fullscreen mode Exit fullscreen mode

Now we can do this:

const { promise, resolve, reject } = Promise.withResolvers();
Enter fullscreen mode Exit fullscreen mode

So we can utilize this to expose a cancel method:

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  let rejected = false;
  const { promise, resolve, reject } = Promise.withResolvers<T>();

  return {
    run: () => {
      if (!rejected) {
        asyncFn().then(resolve, reject);
      }

      return promise;
    },

    cancel: () => {
      rejected = true;
      reject(new Error('CanceledError'));
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

Then we can use it with the following test code:

const sleep = (ms: number) => new Promise(res => setTimeout(res, ms));

const ret = buildCancelableTask(async () => {
  await sleep(1000);
  return 'Hello';
});

(async () => {
  try {
    const val = await ret.run();
    console.log('val: ', val);
  } catch (err) {
    console.log('err: ', err);
  }
})();

setTimeout(() => {
  ret.cancel();
}, 500);
Enter fullscreen mode Exit fullscreen mode

Here, we preset the task to take at least 1000ms, but we cancel it within the next 500ms, so you will see:

Note that this is not true cancellation but an early rejection. The original asyncFn() will continue to execute until it resolves or rejects, but it doesn’t matter because the promise created with Promise.withResolvers<T>() has already been rejected.

Method 2: Using AbortController

Just like we cancel fetch requests, we can implement a listener to achieve early rejection. It looks like this:

const buildCancelableTask = <T>(asyncFn: () => Promise<T>) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        const cancelTask = () => reject(new Error('CanceledError'));

        if (abortController.signal.aborted) {
          cancelTask();
          return;
        }

        asyncFn().then(resolve, reject);

        abortController.signal.addEventListener('abort', cancelTask);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};
Enter fullscreen mode Exit fullscreen mode

It has the same effect as mentioned above but uses AbortController. You can use other listeners here, but AbortController provides the additional benefit that if you call cancel multiple times, it won’t trigger the 'abort' event more than once.

Based on this code, we can go further to build a cancelable fetch. This can be useful in scenarios like sequential requests, where you might want to discard previous request results and use the latest request results.

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>,
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error('CanceledError'));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

const ret = buildCancelableFetch(async signal => {
  return fetch('http://localhost:5000', { signal }).then(res =>
    res.text(),
  );
});

(async () => {
  try {
    const val = await ret.run();
    console.log('val: ', val);
  } catch (err) {
    console.log('err: ', err);
  }
})();

setTimeout(() => {
  ret.cancel();
}, 500);
Enter fullscreen mode Exit fullscreen mode

Please note that this does not affect the server-side processing logic; it merely causes the browser to discard/cancel the request. In other words, if you send a POST request to update user information, it may still take effect. Therefore, this is more commonly used in scenarios where a GET request is made to fetch new data.

Building a Simple Sequential Request React Hook

We can further encapsulate a simple sequential request React hook:

import { useCallback, useRef } from 'react';

type RequestFn<T> = (signal: AbortSignal) => Promise<T>;

const buildCancelableFetch = <T>(
  requestFn: (signal: AbortSignal) => Promise<T>,
) => {
  const abortController = new AbortController();

  return {
    run: () =>
      new Promise<T>((resolve, reject) => {
        if (abortController.signal.aborted) {
          reject(new Error('CanceledError'));
          return;
        }

        requestFn(abortController.signal).then(resolve, reject);
      }),

    cancel: () => {
      abortController.abort();
    },
  };
};

function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;

  return ref;
}

export function useSequentialRequest<T>(requestFn: RequestFn<T>) {
  const requestFnRef = useLatest(requestFn);
  const currentRequest = useRef<{ cancel: () => void } | null>(null);

  return useCallback(() => {
    if (currentRequest.current) {
      currentRequest.current.cancel();
    }

    const { run, cancel } = buildCancelableFetch(
      requestFnRef.current,
    );
    currentRequest.current = { cancel };

    const promise = run().then(res => {
      currentRequest.current = null;
      return res;
    });

    return promise;
  }, [requestFnRef]);
}
Enter fullscreen mode Exit fullscreen mode

Then we can simply use it:

import { useSequentialRequest } from './useSequentialRequest';

export function App() {
  const run = useSequentialRequest(async (signal: AbortSignal) => {
    const ret = await fetch('http://localhost:5000', { signal }).then(
      res => res.text(),
    );
    console.log(ret);
  });

  return (
    <button onClick={run}>Run</button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This way, when you click the button multiple times quickly, you will only get the latest request data, discarding the previous requests.

Do you have other use cases? Feel free to share with me!

If you find this helpful, please consider subscribing to my newsletter for more insights on web development. Thank you for reading!

Top comments (4)

Collapse
 
best_codes profile image
Best Codes

Hmm, I did not know this! This is actually pretty useful. Thanks for sharing!

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Interesting article.

It would be wise to provide dependencies to useSequentialRequest as the example at the end here will cancel/re-request on every render as the function is changing each time.

Collapse
 
zacharylee profile image
Zachary Lee

The line const requestFnRef = useLatest(requestFn); causes it to always reference the latest function with the same ref. This means the useCallback returned by useSequentialRequest will never change.

Collapse
 
miketalbot profile image
Mike Talbot ⭐

Ah yeah, neat :)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.