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)
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 :)
Top comments (2)
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...
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.