How to Annul Promises in JavaScript

Creating Cancelable Tasks in JavaScript with Promise.withResolvers and AbortController

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;
});

Now we can do this:

const { promise, resolve, reject } = Promise.withResolvers();

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'));
    },
  };
};

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);

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();
    },
  };
};

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);

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';

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: (signal: AbortSignal) => Promise<T>,
) {
  const requestFnRef = useLatest(requestFn);
  const currentRequest = useRef<{ cancel: () => void } | null>(null);

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

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

    return run().finally(() => {
      if (currentRequest.current?.cancel === cancel) {
        currentRequest.current = null;
      }
    });
  }, [requestFnRef]);
}

Then we can simply use it:

import { useSequentialRequest } from './useSequentialRequest';

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

  return <button onClick={run}>Run</button>;
}

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

Building an Optimized Sequential Request React Hook

If we need a more comprehensive sequential request React Hook, there is still room for improvement in the example provided above. For instance:

  • We can use a unique AbortController until it is actually needed, reducing the cost of creating one each time.

  • We can use generics to build a request method that supports passing any arguments.

Here's the code:

import { useCallback, useRef } from 'react';

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

export function useSequentialRequest<Args extends unknown[], Data>(
  requestFn: (signal: AbortSignal, ...args: Args) => Promise<Data>,
) {
  const requestFnRef = useLatest(requestFn);

  const running = useRef(false);
  const abortController = useRef<AbortController | null>(null);

  return useCallback(
    async (...args: Args) => {
      if (running.current) {
        abortController.current?.abort();
        abortController.current = null;
      }

      running.current = true;

      const controller = abortController.current ?? new AbortController();
      abortController.current = controller;

      return requestFnRef.current(controller.signal, ...args).finally(() => {
        if (controller === abortController.current) {
          running.current = false;
        }
      });
    },
    [requestFnRef],
  );
}

It's worth noting that in the finally block, we need to check if the current controller is equal to abortController.current to prevent race conditions: this ensures that we only update the state when the currently active request completes. Conversely, if they are not equal, it means the finally block belongs to a canceled request and should not modify the running.current state.

Here’s how to use it:

import { useState } from 'react';
import { useSequentialRequest } from './useSequentialRequest';

export default function Home() {
  const [data, setData] = useState('');

  const run = useSequentialRequest(async (signal: AbortSignal, query: string) =>
    fetch(`/api/hello?query=${query}`, { signal }).then((res) => res.text()),
  );

  const handleInput = async (queryStr: string) => {
    try {
      const res = await run(queryStr);
      setData(res);
    } catch {
      // ignore
    }
  };

  return (
    <>
      <input
        placeholder="Please input"
        onChange={(e) => {
          handleInput(e.target.value);
        }}
      />
      <div>Response Data: {data}</div>
    </>
  );
}

You can experience it online: try typing quickly, and it will cancel previous requests while always keeping the latest response.

If you find my content helpful, please consider subscribing. I send a weekly newsletter every Sunday with the latest web development updates. Thanks for your support!

Reply

or to participate.