Using AbortController (with React Hooks and TypeScript) to cancel window.fetch requests
Tags: web, react, typescript, javascript
Published: 2019-03-27 20:51:04

*Originally posted on [bilaw.al/abortcontroller.html](https://bilaw.al/abortcontroller.html)*

I have longed for being able to cancel `window.fetch` requests in JavaScript. It is something particularly useful, especially to adhere to [React's Lifecycle](https://reactjs.org/docs/state-and-lifecycle.html), and even more so with the introduction of React Hooks.

Thankfully, we have something called `AbortController`!

```js
const abortController = new AbortController()

const promise = window
  .fetch('https://api.example.com/v1/me', {
    headers: {Authorization: `Bearer [my access token]`},
    method: 'GET',
    mode: 'cors',
    signal: abortController.signal,
  })
  .then(res => res.json())
  .then(res => {
    console.log(res.me)
  })
  .catch(err => {
    console.error('Request failed', err)
  })

// Cancel the request if it takes more than 5 seconds
setTimeout(() => abortController.abort(), 5000)
```

As you should expect, **this will cancel the request after 5 seconds.** Pretty cool, eh?

In our `catch`, it will give us an `AbortError` error with the message `The user aborted a request.` so we could even rewrite our error checking to consider this:

```js
promise.catch(err => {
  if (err.name === 'AbortError') {
    console.error('Request took more than 5 seconds. Automatically cancelled.')
    return
  }

  // It wasn't that the request took longer than 5 seconds.
  console.error(err.message)
})
```

## React Hooks?

Of course, let's dive into that. This is something along the lines of what you'll want:

```jsx
// src/hooks/useProfileInformation.jsx
import {useState, useEffect} from 'react'

export function useProfileInformation({accessToken}) {
  const [profileInfo, setProfileInfo] = useState(null)

  useEffect(() => {
    const abortController = new AbortController()

    window
      .fetch('https://api.example.com/v1/me', {
        headers: {Authorization: `Bearer ${accessToken}`},
        method: 'GET',
        mode: 'cors',
        signal: abortController.signal,
      })
      .then(res => res.json())
      .then(res => setProfileInfo(res.profileInfo))

    return function cancel() {
      abortController.abort()
    }
  }, [accessToken])

  return profileInfo
}

// src/app.jsx
import React from 'react'
import {useProfileInformation} from './hooks/useProfileInformation'

export function App({accessToken}) {
  try {
    const profileInfo = useProfileInformation({accessToken})

    if (profileInfo) {
      return <h1>Hey, ${profileInfo.name}!</h1>
    } else {
      return <h1>Loading Profile Information</h1>
    }
  } catch (err) {
    return <h1>Failed to load profile. Error: {err.message}</h1>
  }
}
```

## React Hooks and TypeScript?

Oh, you! Just take it already.

```tsx
// src/hooks/useProfileInformation.tsx
import {useState, useEffect} from 'react'

export interface ProfileRequestProps {
  accessToken: string
}

export interface ProfileInformation {
  id: number
  firstName: string
  lastName: string
  state: 'free' | 'premium'
  country: {
    locale: string
  }
}

export function useProfileInformation({accessToken}: ProfileRequestProps): ProfileInformation | null {
  const [profileInfo, setProfileInfo] = useState(null)

  useEffect(() => {
    const abortController = new AbortController()

    window
      .fetch('https://api.example.com/v1/me', {
        headers: {Authorization: `Bearer ${accessToken}`},
        method: 'GET',
        mode: 'cors',
        signal: abortController.signal,
      })
      .then((res: Response) => res.json())
      .then((resProfileInfo: ProfileInformation) => setProfileInfo(resProfileInfo))

    return function cancel() {
      abortController.abort()
    }
  }, [accessToken])

  return profileInfo
}

// src/app.tsx
import React, { ReactNode } from 'react'
import {useProfileInformation, ProfileRequestProps, ProfileInformation} from './hooks/useProfileInformation'

export function App({accessToken}: ProfileRequestProps) : ReactNode {
  try {
    const profileInfo: ProfileInformation = useProfileInformation({accessToken})

    if (profileInfo) {
      return <h1>Hey, ${profileInfo.name}!</h1>
    } else {
      return <h1>Loading Profile Information</h1>
    }
  } catch (err) {
    return <h1>Failed to load profile. Error: {err.message}</h1>
  }
}
```

## Testing

It's supported in `jest` and `jsdom` by default, so you're all set. Something like?

```js
// src/utils.js
export const getProfileInformation = () => {
  const abortController = new AbortController()
  const response = window
    .fetch('https://api.example.com/v1/me', {signal: abortController.signal})
    .then(res => res.json())
  return {response, abortController}
}

// src/__tests__/utils.test.js
import utils from '../utils'

describe('Get Profile Information', () => {
  it('raises an error if we abort our fetch', () => {
    expect(() => {
      const profile = getProfileInformation()
      profile.abortController.abort()
    }).toThrowError()
  })
})
```

## Promises

Want to see how you'd use `AbortController` for Promises? Check out [make-abortable](https://github.com/jomaxx/make-abortable) written by fellow colleague Josef Blake

## Gotchas?

### Gotcha #1: No support of destructuring

Sadly, we cannot destruct our `new AbortController()` as such:

```js
const {signal, abort} = new AbortController()

window
  .fetch('https://api.example.com/v1/me', {signal})
  .then(res => res.json())
  .then(res => console.log(res))

setTimeout(() => abort(), 5000)
```

When we invoke the `abort()` method, it invokes an `Uncaught TypeError: Illegal invocation` error when because it is a prototype implementation that depends on `this`.

## Conclusions

I have read up on `AbortController` a while ago, but glad that I have finally had a chance to fully check it out. It is impressively supported across all browsers (except Safari, unsurprisingly) so you should be able to use it in your projects :)