How to Annul Promises in JavaScript

Zachary Lee - Jul 1 - - Dev Community

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

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

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

img

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

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

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

img


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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player