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, and even more so with the introduction of React Hooks.

Thankfully, we have something called AbortController!

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:

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:

// 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.

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

// 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 written by fellow colleague Josef Blake

Gotchas?

Gotcha #1: No support of destructuring

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

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 :)