DEV Community

Keff
Keff

Posted on

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!

Latest comments (6)

Collapse
 
nombrekeff profile image
Keff

Interesting solution, no need to create custom promise here. I went the custom promise route because I was approaching it in an object-oriented way for another post.

Collapse
 
jcubic profile image
Jakub T. Jankiewicz • Edited

Nice idea. But your implementation is problematic because you modify this.promise. You should create new CustomPromise instead. Example that will 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
Collapse
 
nombrekeff profile image
Keff

Ohh thanks for pointing it out, I did not think much about this, I imagined there might be some problems with the implementation but did not think more about it.

You mean returning a new promise instead of modifying this.promise right?

Collapse
 
jcubic profile image
Jakub T. Jankiewicz • Edited

Yes, just use new CustomPromise and pass the result of then into that new promise.

This is the implementation of promise wrapper in my project (I still have part of the code using prototypes).

    function QuotedPromise(promise) {
        var internal = {
            pending: true,
            rejected: false,
            fulfilled: false,
            reason: undefined,
            type: undefined
        };
        // then added to __promise__ is needed otherwise rejection
        // will give UnhandledPromiseRejectionWarning in Node.js
        promise = promise.then(v => {
            internal.type = type(v);
            internal.fulfilled = true;
            internal.pending = false;
            return v;
        });
        // promise without catch, used for valueOf - for rejecting
        // that should throw an error when used with await
        read_only(this, '_promise', promise, { hidden: true });
        if (is_function(promise.catch)) {
            // prevent exception on unhandled rejecting when using
            // '>(Promise.reject (new Error "zonk")) in REPL
            promise = promise.catch((err) => {
                internal.rejected = true;
                internal.pending = false;
                internal.reason = err;
            });
        }
        Object.keys(internal).forEach(name => {
            Object.defineProperty(this, `__${name}__`, {
                enumerable: true,
                get: () => internal[name]
            });
        });
        read_only(this, '__promise__', promise);
        // prevent resolving when returned from real promise #153
        this.then = false;
    }
    // ----------------------------------------------------------------------
    QuotedPromise.prototype.then = function(fn) {
        return new QuotedPromise(this.valueOf().then(fn));
    };
    // ----------------------------------------------------------------------
    QuotedPromise.prototype.catch = function(fn) {
        return new QuotedPromise(this.valueOf().catch(fn));
    };
    // ----------------------------------------------------------------------
    QuotedPromise.prototype.valueOf = function() {
        if (!this._promise) {
            throw new Error('QuotedPromise: invalid promise created');
        }
        return this._promise;
    };
    // ----------------------------------------------------------------------
    QuotedPromise.prototype.toString = function() {
        if (this.__pending__) {
            return QuotedPromise.pending_str;
        }
        if (this.__rejected__) {
            return QuotedPromise.rejected_str;
        }
        return `#<js-promise resolved (${this.__type__})>`;
    };
    QuotedPromise.pending_str = '#<js-promise (pending)>';
    QuotedPromise.rejected_str = '#<js-promise (rejected)>';
Enter fullscreen mode Exit fullscreen mode

The pattern is very similar to Monads (in fact some explanaions shows promises as example of modals) and Monad should never mutate the data.

Thread Thread
 
nombrekeff profile image
Keff

Nice, yes I ended up solving it 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

Though not complete, it does the job and could be improved as the needs arise.

Thanks again for pointing this out, I've made an edit in the post to explain to future readers.

Thread Thread
 
nombrekeff profile image
Keff

The pattern is very similar to Monads (in fact some explanaions shows promises as example of modals) and Monad should never mutate the data.

Ohh cool, never thought of promises as monads, makes sense