Build a useFetch Hook with AbortController

Build a useFetch Hook that fetches data and cancels in-flight requests with AbortController, so stale responses never overwrite fresh ones.

6 min read

A useFetch Hook runs a fetch request and cancels it when the component unmounts or the URL changes. AbortController is the browser API that makes the cancellation real, so a stale response never lands in state. The Hook returns data while the request is pending and re-fetches automatically when the URL changes.

Why cancel in-flight requests

Typing quickly into a search box can start a new request on every keystroke. Network responses can arrive in any order, so an older response sometimes arrives after a newer one and overwrites it. Canceling the previous request removes the race instead of only ignoring its result.

  • Every keystroke can start a request.
  • Older responses can arrive after newer ones.
  • Aborting the old request stops the race at the source.

AbortController is supported in every current browser and in Node.js, so the same pattern works across environments.

Build the Hook

The useFetch Hook creates one controller per request, passes its signal to fetch, and aborts it in the cleanup function.

App.jsxApp.jsx
import { useEffect, useState } from "react";
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    fetch(url, { signal: controller.signal })
      .then((response) => response.json())
      .then((result) => { setData(result); setLoading(false); });
    return () => controller.abort();
  }, [url]);
  return { data, loading };
}

Passing controller.signal into fetch connects the request to the controller. The cleanup function calls controller.abort, which cancels the request when the url changes or the component unmounts. This version still has a gap: a rejected fetch, including an aborted one, has no catch handler.

Add error handling

Declare an error state next to data and loading, clear it when a new request starts, and add a catch to the same fetch chain.

App.jsxApp.jsx
setError(null);
fetch(url, { signal: controller.signal })
  .then((response) => response.json())
  .then((result) => { setData(result); setLoading(false); })
  .catch((err) => {
    if (err.name !== "AbortError") {
      setError(err);
      setLoading(false);
    }
  });

This replaces the plain fetch call from the version above, still inside the same Effect. The catch block checks error.name so an aborted request never overwrites state with a fake failure.

Skipping the AbortError case leaves loading and error untouched, so the next request's own setLoading(true) is the only state change that reaches the screen. A genuine network error still reaches error, and the Hook now returns { data, error, loading }.

Use the Hook in a component

App.jsxApp.jsx
function UserList() {
  const { data, error, loading } = useFetch("/api/users");
  if (loading) return <p>Loading...</p>;
  if (error) return <p role="alert">Could not load users.</p>;
  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

While the request runs, loading stays true and the component shows Loading. A failed request sets error and the list never renders with missing data. When the response arrives, data holds the result and the list renders.

Changing the url starts a new request, aborts the old one, and resets loading to true for the new fetch. The key on each item keeps list updates stable while fresh users arrive.

Why the AbortError check matters

An aborted fetch rejects with a DOMException whose name is AbortError. Without the check in the catch block, canceling a request in flight would set error and flash a false failure message right before the next request's data arrives.

A dedicated loading flag also avoids overloading data to mean three different things: unset, empty, and pending. With data, error, and loading exposed separately, the component can render a spinner, an error message, or the resolved list without guessing which situation it is in.

When a data library does this for you

Once an app needs caching, retries, and optimistic updates, a hand-built useFetch stops being enough. TanStack Query and similar libraries wrap all of this, including request cancellation, behind a more complete API.

See how to create a custom Hook for the extraction steps, and when you actually need useEffect for the Effect timing behind the fetch.

Rune AI

Rune AI

Key Insights

  • Create one AbortController per Effect run and pass its signal to fetch.
  • Call controller.abort in the Effect cleanup.
  • Skip the AbortError case when recording errors.
  • Return data and error separately, and expose a loading state.
  • Cancel at the source instead of only ignoring stale responses.
RunePowered by Rune AI

Frequently Asked Questions

What does AbortController do in a fetch Hook?

It cancels the current request when the URL changes or the component unmounts. The cleanup function calls controller.abort, and fetch rejects with an AbortError.

How do I tell an aborted request from a real error?

Check error.name. An aborted fetch rejects with a DOMException named AbortError, while a genuine failure has a different name, such as TypeError.

Why cancel instead of just ignoring stale responses?

Canceling stops the network work itself, not only the state update. It removes the race at the source and frees browser resources.

Conclusion

A useFetch Hook wraps fetch in an Effect and ties each request to an AbortController. The cleanup aborts the request when its inputs change or the component unmounts, which removes stale-response races instead of only ignoring their results.