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 this helpful, please consider subscribing to my newsletter for more insights on web development. Thank you for reading!
Top comments (5)
Hmm, I did not know this! This is actually pretty useful. Thanks for sharing!
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.
The line
const requestFnRef = useLatest(requestFn);
causes it to always reference the latest function with the same ref. This means theuseCallback
returned byuseSequentialRequest
will never change.Ah yeah, neat :)
useful
Some comments may only be visible to logged-in visitors. Sign in to view all comments.