C.W.K.
Stream
Lesson 05 of 07 · published

fetch + AbortController — Cancelling in Flight

~11 min · fetch, abortcontroller, cancellation

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Every uncancelled fetch is a future race condition. AbortController is the standard, free, no-library-needed way to cancel an in-flight request when the user changes their mind.

The race condition

A user types in a search box. Each keystroke fires a fetch. The fetches return out of order — the response for 'r' arrives after the response for 're'. Without cancellation, the older response wins (because it lands later in time), and the UI shows results for the wrong query. The fix is to abort superseded requests.

The API

Create a controller, pass its signal to fetch, call controller.abort() when you want to cancel. The fetch promise rejects with an AbortError. Filter that error type out of your error handling (it's expected, not exceptional).

Where to integrate

  • useEffect — create the controller at the top, abort in the cleanup function (returns from the effect).
  • Custom hook — same pattern, just inside the hook.
  • Event handlers — store the controller in a ref so subsequent handler calls can abort the previous one.

Timeouts

For a hard timeout, combine AbortController with AbortSignal.timeout(ms): fetch(url, { signal: AbortSignal.timeout(5000) }) aborts after 5 seconds. For manual + timeout, use AbortSignal.any([ctrl.signal, AbortSignal.timeout(5000)]) — abort if either fires.

Every fetch needs a way to be cancelled. 'I don't need cancellation' is usually 'I haven't hit the race condition yet.' Build the discipline once — every fetch in a hook gets a signal, every cleanup aborts. The bug it prevents is the kind QA can't reproduce.

Code

useEffect — AbortController for fetch lifecycle·tsx
import { useEffect, useState } from "react";

function useSearchResults(query: string) {
  const [results, setResults] = useState<string[]>([]);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    const ctrl = new AbortController();
    fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ctrl.signal })
      .then((r) => r.json())
      .then(setResults)
      .catch((e) => {
        // AbortError is expected — query changed mid-flight.
        if (e.name !== "AbortError") throw e;
      });
    return () => ctrl.abort();
  }, [query]);

  return results;
}
Event handler with ref-stored controller·tsx
import { useRef } from "react";

function useSave() {
  // Latest controller — previous call's controller gets aborted.
  const ctrlRef = useRef<AbortController | null>(null);

  const save = async (payload: object) => {
    // Abort any previous in-flight save.
    ctrlRef.current?.abort();
    const ctrl = new AbortController();
    ctrlRef.current = ctrl;
    try {
      const r = await fetch("/api/save", {
        method: "POST",
        body: JSON.stringify(payload),
        signal: ctrl.signal,
      });
      return await r.json();
    } catch (e) {
      if ((e as Error).name === "AbortError") return null; // superseded
      throw e;
    }
  };

  return save;
}
AbortSignal.timeout — hard cancellation deadline·ts
async function fetchWithTimeout<T>(url: string, ms: number): Promise<T> {
  const r = await fetch(url, { signal: AbortSignal.timeout(ms) });
  return r.json();
}

// AbortSignal.any — combine multiple signals (user cancel + timeout).
async function fetchUserCancellable<T>(
  url: string,
  userSignal: AbortSignal,
  ms: number
): Promise<T> {
  const r = await fetch(url, {
    signal: AbortSignal.any([userSignal, AbortSignal.timeout(ms)]),
  });
  return r.json();
}

External links

Exercise

Build a search input wired to useSearchResults. Throttle it with useDebouncedValue (Track 3 lesson 4). Verify that rapid typing doesn't produce flickering wrong results — the AbortController cancels superseded requests, the debounce reduces the rate. Add a 3-second timeout via AbortSignal.any and prove a deliberately slow endpoint shows a timeout error after 3s.
Hint
If you see results from an old query, your AbortController setup is wrong — likely the cleanup isn't running, or the controller is created outside the useEffect (so it's the same one across renders).

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.