DEV Community

Bilawal Hameed
Bilawal Hameed

Posted on

Using AbortController (with React Hooks and TypeScript) to cancel window.fetch requests

Originally posted on 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, 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)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

Top comments (2)

Collapse
 
sebastienlorber profile image
Sebastien Lorber

great :)

Just want to add a tiny detail that you missed: if abortion happens during the response.json() call, the abortion won't abort anything and you'll still setProfileInfo :)

Check my long article on the subject if you want more details: dev.to/sebastienlorber/handling-ap...

Collapse
 
oleggromov profile image
Oleg Gromov

Nice!
I bet abort could be used separately after binding it to the instance: const abort = abortController.abort.bind(abortController), although I don't see how this is useful unless you want to pass it around.