ESC

Type to search the knowledge base.

useFetch With AbortController

Fetch in an effect with a signal, ignore aborted errors, and prevent the slow response from winning a race after the url changes.

intermediate6 min read
  • react
  • hooks
  • fetch
  • abortcontroller

Type "re", then "react". The first request is slower. If you ignore cancellation, "re" lands last and paints the wrong list. That is the race useFetch exists to kill: the in-flight request whose url is no longer current must not win.

The browser tool is AbortController. Pass signal into fetch, call abort() in the effect cleanup, and treat AbortError as control flow, not as status: 'error'. Swallowing every error is how you hide 500s. Ignoring only abort is the contract.

This is still an effect that syncs the tree to the network, not a replacement for a cache library. Interviews want the race, the cleanup, and the status flags. They do not want you to invent React Query from memory.

  • Create a new AbortController per effect run. After abort, that signal stays aborted forever.
  • Cleanup calls controller.abort(). That covers unmount, url change, and Strict Mode’s setup → cleanup → setup.
  • catch returns early on err.name === 'AbortError' (or err is a DOMException with that name). Do not set error state.
  • fetch fulfills on HTTP 404/500. Check response.ok before json().
  • options as an object literal in the dependency array retriggers the request every render. Depend on url (and stable fields), not a fresh object.

Abort on cleanup, then ignore AbortError

import { useEffect, useState } from 'react';

function useFetch(url, options) {
  const [state, setState] = useState({
    status: url ? 'loading' : 'idle',
    data: null,
    error: null,
  });

  useEffect(() => {
    if (!url) {
      setState({ status: 'idle', data: null, error: null });
      return undefined;
    }

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

    setState((prev) => ({ ...prev, status: 'loading', error: null }));

    fetch(url, { ...options, signal })
      .then((res) => {
        if (!res.ok) {
          throw new Error(`HTTP ${res.status}`);
        }
        return res.json();
      })
      .then((data) => {
        setState({ status: 'success', data, error: null });
      })
      .catch((err) => {
        if (err.name === 'AbortError') return;
        setState({ status: 'error', data: null, error: err });
      });

    return () => {
      controller.abort();
    };
  }, [url]); // see options footgun below

  return state;
}

Procedure:

  1. url is empty → idle, no request.
  2. Otherwise construct a controller and set loading (keep previous data if you want a stale-while-revalidate flash; the snippet clears error only).
  3. fetch(url, { ...options, signal }). The signal must win if the caller also passed signal; do not let a spread overwrite yours unless you compose with AbortSignal.any.
  4. On !res.ok, throw. On JSON, success.
  5. On abort, return. On any other rejection (network, parse, HTTP throw), error.
  6. Cleanup abort. The next effect run starts a new controller.

AbortController rejects the fetch promise with AbortError. Fetch API Fundamentals is the rest: bodies are one-shot, json() can throw, credentials are opt-in.

Why abort beats “ignore the result”

A sequence number also prevents the slow response from calling setState:

let seq = 0;
useEffect(() => {
  const id = ++seq;
  fetch(url)
    .then((res) => res.json())
    .then((data) => {
      if (id !== seq) return;
      setState({ status: 'success', data, error: null });
    });
}, [url]);

That drops stale data. It does not cancel the HTTP request. The browser still downloads the body; the server still works. Abort cancels the client side of the fetch (the server may still finish unless it observes disconnect). In an interview, say both, then pick abort for fetch.

The ignore-only pattern is what you fall back to when the API cannot take a signal. See Cancelable Async Tasks.

Status flags

status UI
idle No url
loading Spinner; optionally keep last data
success Render data
error Message + retry

A boolean loading plus a leftover error is how you show a red banner over a successful second request. One discriminant is easier to exhaust in the UI.

Retry is setUrl to the same string (no-op) or a manual reload counter in the deps. Do not call the fetch function from render.

Strict Mode double fetch

In development, React Strict Mode runs the effect twice: fetch, abort, fetch again. That is the missing-cleanup detector. If you do not abort, you get two in-flight requests and a possible race with yourself. If you abort but set error on AbortError, the UI flashes an error between the two runs.

Production mounts once. Design for setup → cleanup → setup anyway (navigate away and back).

Footguns

options in the dependency array. useFetch(url, { headers: { Accept: 'application/json' } }) passes a new object every render. The effect re-runs, aborts the previous fetch, and you spin forever. Fix: depend on url and primitive fields (method), store the rest in a ref, or require the caller to memoize. Do not JSON.stringify(options) as a dep unless you control key order.

Setting error on abort. err.name === 'AbortError' is the check. Some runtimes use DOMException. fetch abort does not mean the server failed.

No res.ok check. fetch only rejects on network failure and abort. HTTP 500 with an HTML body will blow up in res.json() or, worse, parse as success if the server returns JSON errors.

Spreading options after signal. { signal, ...options } lets the caller overwrite your controller. { ...options, signal } keeps abort yours. If the caller needs their own signal, AbortSignal.any([controller.signal, options.signal]) where that exists.

Parsing after abort. await res.json() after a cancelled fetch can still reject with abort. The same catch handles it if you do not setState first. Avoid:

const res = await fetch(url, { signal });
const data = await res.json();
if (signal.aborted) return; // too late: you already waited, but at least skip setState
setState({ status: 'success', data, error: null });

The if (signal.aborted) guard is a belt. Cleanup abort plus ignoring AbortError is the suspenders.

Auth headers in the hook. A generic useFetch that injects tokens becomes a secret-sprawling singleton. Pass headers in, or use a data library.

Timeouts: AbortSignal.timeout(ms) or Timeout a Promise. Retries: Retry a Promise N Times, and do not retry 4xx you caused. Neither belongs inside the minimum hook unless asked.

Interview out-loud answer

I fetch inside useEffect with a fresh AbortController and pass signal to fetch. The cleanup aborts, so a url change or unmount cannot let the slow response win. I ignore AbortError and I do not treat it as UI error. I still check response.ok because fetch does not throw on 404. Options objects are not deps unless they are stable. Strict Mode will abort and refetch in development; that is expected if cleanup is correct.

Further reading

Related guides