🛑AbortController

An AbortController is how you cancel async work in the browser (and Node). You keep the controller and call abort() on it. You pass its signal to the thing you might want to cancel.

const controller = new AbortController()

fetch(url, { signal: controller.signal })

controller.abort() // the fetch promise rejects with an AbortError
  • A controller is single-use. Once aborted it stays aborted, so make a new one for the next job.
  • One signal can be passed to many things. A single abort() cancels them all.
  • abort(reason) takes an optional reason. The promise rejects with that instead of the default AbortError.

More on fetch itself, including error handling: fetch.

Cancel a fetch

const controller = new AbortController()
cancelButton.addEventListener('click', () => controller.abort())

try {
  const res = await fetch('/api/report', { signal: controller.signal })
  render(await res.json())
} catch (error) {
  if ((error as Error).name === 'AbortError') return // cancelled on purpose
  throw error
}

Cancel the previous request

The classic race: the user types “re”, then “react”. If the “re” response arrives last, it overwrites the right results. Abort the old request before you start a new one.

let controller: AbortController | undefined

async function search(query: string) {
  controller?.abort()
  controller = new AbortController()

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
    renderResults(await res.json())
  } catch (error) {
    if ((error as Error).name !== 'AbortError') throw error
  }
}

Pair it with debounce so you send fewer requests in the first place.

import { debounce } from './debounce'

const debouncedSearch = debounce(search, 300)

input.addEventListener('input', (e) => {
  debouncedSearch((e.target as HTMLInputElement).value.trim())
})

They do different jobs, so you want both:

  • Debounce waits until typing pauses, so “r”, “re”, “rea”… don’t each send a request.
  • Abort handles the request that is already in flight. If the user pauses, the request starts, and then they type again, the old request is cancelled before the new one starts.

Timeouts

You don’t need a controller for a timeout. AbortSignal.timeout() makes a signal that aborts itself.

const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
// rejects with a TimeoutError if it takes longer than 5s

Need a timeout and a cancel button? Combine signals with AbortSignal.any(). The first one to fire wins.

const controller = new AbortController()

const res = await fetch(url, {
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
})

Remove event listeners

Pass the same signal to many addEventListener calls. One abort() removes them all, and you don’t need to keep a reference to each handler.

const controller = new AbortController()
const { signal } = controller

window.addEventListener('resize', onResize, { signal })
window.addEventListener('scroll', onScroll, { passive: true, signal })
document.addEventListener('keydown', onKeyDown, { signal })

// later, e.g. when a modal closes
controller.abort()

Make your own functions cancellable

Accept a signal like fetch does. Check it between steps.

async function processAll(items: Item[], { signal }: { signal?: AbortSignal } = {}) {
  for (const item of items) {
    signal?.throwIfAborted() // throws the abort reason if aborted
    await processItem(item)
  }
}

Wrap a callback-based API by listening for the abort event.

const wait = (ms: number, signal?: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    signal?.throwIfAborted()
    const id = setTimeout(resolve, ms)
    signal?.addEventListener(
      'abort',
      () => {
        clearTimeout(id)
        reject(signal.reason)
      },
      { once: true },
    )
  })

React

Clean up in useEffect

Abort in the cleanup function. The request is cancelled when the component unmounts or when id changes, so an old response can never set state.

function User({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null)

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

    fetch(`/api/users/${id}`, { signal: controller.signal })
      .then((res) => res.json())
      .then(setUser)
      .catch((error) => {
        if (error.name !== 'AbortError') console.error(error)
      })

    return () => controller.abort()
  }, [id])

  return user ? <h1>{user.name}</h1> : <p>Loading…</p>
}

The same pattern cleans up event listeners.

useEffect(() => {
  const controller = new AbortController()
  window.addEventListener('keydown', onKeyDown, { signal: controller.signal })
  window.addEventListener('resize', onResize, { signal: controller.signal })
  return () => controller.abort()
}, [])

React Query

React Query makes the controller for you. It passes a signal to your queryFn. Hand it to fetch, and React Query aborts the request when:

  • the query key changes (for example, a new search term)
  • the component unmounts and nothing else uses the query
  • you call queryClient.cancelQueries()
const { data } = useQuery({
  queryKey: ['users', id],
  queryFn: async ({ signal }) => {
    const res = await fetch(`/api/users/${id}`, { signal })
    if (!res.ok) throw new Error('Failed to load user')
    return res.json()
  },
})

If you don’t pass signal to fetch, React Query can’t cancel the request. It keeps running to the end.