Cancel fetch requests, and a way to abstract it
Tags: javascript, tutorial, webdev, programming
Published: 2021-08-22 17:52:15


Working on another post/tutorial on **fetch**, I found myself needing to cancel individual fetch requests. 

I investigated a bit, and learned about [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/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:

```js
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_):

```js
function fetchTodos(signal) {
    return fetch('/todos', { signal });
}

function fetchUsers(signal) {
    return fetch('/users', { signal });
}
```

After that we create an instance of [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/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:

```js
const controller = new AbortController();
```

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

```js
fetchTodos(controller.signal);
fetchUsers(controller.signal);
```

**What's this [signal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) 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):
```js
controller.abort();
```

> 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?**

```js
// ... 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:

```js
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:

```js
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:

```js
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:
```js
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.

```js
function fetchTodos() {
    return abortableFetch('/todos', { signal: globalSignal });
}
```
This signal will override the default one created in `abortableFetch`


## Complete code

```js
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:

```js
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:
```js
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
* [fetch](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch)
* [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
* [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)


----

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](https://github.com/nombrekeff), or commenting!