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.
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.
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
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
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.
Frequently Asked Questions
What does AbortController do in a fetch Hook?
How do I tell an aborted request from a real error?
Why cancel instead of just ignoring stale responses?
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.
More in this topic
How to Build a Dropdown Menu in React
Build a React dropdown menu with the ARIA menu button pattern. Handle open and close, keyboard arrows, and clicks outside the menu.
How to Animate React Components with Motion
Animate React components with the Motion library. Set up motion, add enter, hover, and exit animations, and respect reduced motion.
Headless UI Components Explained: Logic Without Locked Styling
Understand headless UI components and how libraries like Radix give you unstyled, accessible behavior that you style yourself.