Working on another post/tutorial on fetch, I found myself needing to cancel individual fetch requests.
I investigated a bit, and learned about AbortController (supported in all browsers, except... can you guess who? yeah, IE).
Pretty neat stuff, let me show you how it's used, and I will explain it later on:
function fetchTodos(signal) {
return fetch('/todos', { signal });
}
function fetchUsers(signal) {
return fetch('/users', { signal });
}
const controller = new AbortController();
fetchTodos(controller.signal);
fetchUsers(controller.signal);
controller.abort();
Okay, now let me break that down
First we define two functions that use fetch
to retrieve some data, they also receive a signal argument (explained a bit further):
function fetchTodos(signal) {
return fetch('/todos', { signal });
}
function fetchUsers(signal) {
return fetch('/users', { signal });
}
After that we create an instance of AbortController, this controller will allow us to get a signal to pass to fetch, and it also gives us the option to cancel the request:
const controller = new AbortController();
Then we just pass the signal property of the controller, to both fetch requests:
fetchTodos(controller.signal);
fetchUsers(controller.signal);
What's this signal thing?
Well, basically it's a mechanism to communicate with a DOM request. Not directly though, a reference to the signal is passed to fetch, but, then abort using the controller, which internally interacts with the signal.
As you can see we are passing in the same signal to both requests, this means if we abort on the current controller, it will cancel all ongoing requests.
Finally at any point after running fetch, we can cancel the request (if it's not yet completed):
controller.abort();
Note: When
abort()
is called, thefetch()
promise rejects with aDOMException
namedAbortError
BUT WAIT
What if we try to run fetchTodos
again, after aborting?
// ... previous code
controller.abort();
fetchTodos(controller.signal);
If we pass the same signal it will instantly abort the request.
We would need to create a new controller and signal for the new request, becoming a bit tedious to add to each specific requests.
Lets see the solution I found, by returning a custom object, and generating a signal for each request:
The first thing we need is a class, that will wrap around the fetch promise and optionally the abort controller:
export class CustomRequest {
constructor(requestPromise, abortController) {
if(!(requestPromise instanceof Promise)) {
throw TypeError('CustomRequest expects "promise" argument to be a Promise');
}
// Only check abort controller if passed in, otherwise ignore it
if(abortController && !(abortController instanceof AbortController)) {
throw TypeError('CustomRequest expects "abortController" argument to be an AbortController');
}
this.promise = requestPromise;
this.abortController = abortController;
}
abort() {
if (!this.abortController) return;
return this.abortController.abort();
}
then(fn) {
this.promise = this.promise.then(fn);
return this;
}
catch(fn) {
this.promise = this.promise.catch(fn);
return this;
}
}
CustomRequest
behaves almost exactly like a promise, but we add some extra functionality in the form of the abort method.
Next, create a wrapper around fetch, called abortableFetch
, which will return a new CustomRequest instead of the regular fetch promise:
export function abortableFetch(uri, options) {
const abortController = new AbortController();
const abortSignal = abortController.signal;
const mergedOptions = {
signal: abortSignal,
method: HttpMethods.GET,
...options,
};
const promise = fetch(uri, mergedOptions);
return new CustomRequest(promise, abortController);
}
Let us now change the original example, and apply the new fetch function:
function fetchTodos() {
return abortableFetch('/todos');
}
function fetchUsers() {
return abortableFetch('/users');
}
const todosReq = fetchTodos();
const usersReq = fetchUsers();
// We can now call abort on each individual requests
todosReq.abort();
usersReq.abort();
Much better right?
We can even use is as a regular promise:
const todosReq = fetchTodos();
todosReq.then(...).catch(...);
Another thing to notice, you can still override the signal in case you want to controll all requests with the same signal.
function fetchTodos() {
return abortableFetch('/todos', { signal: globalSignal });
}
This signal will override the default one created in abortableFetch
Complete code
export class CustomRequest {
constructor(requestPromise, abortController) {
if(!(requestPromise instanceof Promise)) {
throw TypeError('CustomRequest expects "promise" argument to be a Promise');
}
// Only check abort controller if passed in, otherwise ignore it
if(abortController && !(abortController instanceof AbortController)) {
throw TypeError('CustomRequest expects "abortController" argument to be an AbortController');
}
this.promise = requestPromise;
this.abortController = abortController;
}
abort() {
if (!this.abortController) return;
return this.abortController.abort();
}
then(fn) {
this.promise = this.promise.then(fn);
return this;
}
catch(fn) {
this.promise = this.promise.catch(fn);
return this;
}
}
export function abortableFetch(uri, options) {
const abortController = new AbortController();
const abortSignal = abortController.signal;
const mergedOptions = {
signal: abortSignal,
method: HttpMethods.GET,
...options,
};
const promise = fetch(uri, mergedOptions);
return new CustomRequest(promise, abortController);
}
function fetchTodos() {
return abortableFetch('/todos');
}
function fetchUsers() {
return abortableFetch('/users');
}
const todosReq = fetchTodos();
const usersReq = fetchUsers();
// We can now call abort on each individual requests
todosReq.abort();
usersReq.abort();
Edit 1
As Jakub T. Jankiewicz pointed out in the comments, there is a problem with the initial implementation, where the following would fail:
const p = abortableFetch('...');
p.then(function() {
// nothing
});
p.then(function(res) {
// this will give error because first then return undefined and modify the promise
res.text();
});
But we can easily solve this like this:
class CustomRequest {
then(fn) {
return new CustomRequest(
this.promise.then(fn),
this.abortController,
);
}
catch(fn) {
return new CustomRequest(
this.promise.catch(fn),
this.abortController,
);
}
}
By returning a new instance of CustomRequest attached to the new promise, instead of overriding this.promise
, we prevent the behaviour reported by Jakub T. Jankiewicz
Summary
Well, for me, this is another weird API, if I'm honest. It does the job, but could have been done better. That aside, we can do some stuff around it and improve our experience a bit.
And to recap, in this post we've:
- seen how to cancel requests in the most simple way,
- detected some weird or tedious things,
- and finally built something on top of it to help us ease the process!
Links
Another quick post, I was in a writing mode this weekend so... I hope you liked it, and found it usefull!