Cancel fetch requests, and a way to abstract it

nombrekeff

Keff

Posted on August 22, 2021

Cancel fetch requests, and a way to abstract it

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

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

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

Then we just pass the signal property of the controller, to both fetch requests:

fetchTodos(controller.signal);
fetchUsers(controller.signal);
Enter fullscreen mode Exit fullscreen mode

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

Note: When abort() is called, the fetch() promise rejects with a DOMException named AbortError

BUT WAIT

What if we try to run fetchTodos again, after aborting?

// ... previous code
controller.abort();

fetchTodos(controller.signal);
Enter fullscreen mode Exit fullscreen mode

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

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

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

Much better right?

We can even use is as a regular promise:

const todosReq = fetchTodos();
todosReq.then(...).catch(...);
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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!

If you did, consider supporting me by reacting to the post, following me here or over on GitHub, or commenting!
💖 💪 🙅 🚩
nombrekeff
Keff

Posted on August 22, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related