React
Data fetching without a library
One effect with AbortController covers request, error and cancellation in thirty lines. Cache, retry and revalidation are the only reasons for a package.
Key points
- One effect with `AbortController` covers request, cancellation and error state, with no dependency.
- `fetch` does not reject on 404 or 500, so the `res.ok` check is mandatory.
- Shared cache, retry and revalidation are the three pieces a library actually adds.
Short answer: a request per component needs nothing more than fetch and an effect with AbortController. State, error and cancellation fit in thirty lines, with no package added to the bundle.
The ordinary case
import { useEffect, useState } from "react";
type State =
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "done"; data: T };
export function useFetch(url: string): State {
const [state, setState] = useState>({ status: "loading" });
useEffect(() => {
const controller = new AbortController();
setState({ status: "loading" });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json() as Promise;
})
.then((data) => setState({ status: "done", data }))
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") return;
setState({ status: "error", error: error as Error });
});
return () => controller.abort();
}, [url]);
return state;
}
The check on res.ok is there because fetch does not reject on a 404 or a 500: the promise resolves and the decision stays with you. The signal cancels the request when the component leaves the tree, so no state update lands on a component that is gone.
What you still write by hand
- Cache: two components asking for the same URL make two requests, unless they share a structure that lives outside the effect.
- Retry: a 503 is not retried on its own, that takes a loop with growing delay and a cap on attempts.
- Revalidation: after a write, the old data stays until something invalidates the copy you saved.
The boundary with libraries
When all three items above are genuinely needed, a library costs less than the code you would write. TanStack Query 5.105.1 is MIT and brings a shared cache, retry and loading state; SWR 2.5.1 is MIT, weighs less and targets revalidation on focus. The practical difference is the size of the API, not quality.
The project, from scratch
npm create vite@latest fetch-demo -- --template react-ts
cd fetch-demo
npm install
npm run dev
Live records in the catalogue
For the interface around the loading state the catalogue has ready records: the shadcn library, the dashboard blocks with tables and empty states, and the spinner component. Every record states its licence on its own page.
The limitation
The hook above deduplicates nothing and shares nothing: under StrictMode effects run twice, so the first request is cancelled and sent again, and in development you see two network calls. That is the behaviour described in why effects run twice, not a bug in your code.
Where to continue
The general model is in what React is; when the data comes from the server, the comparison is in streaming and Suspense in the App Router. If you are picking a library, the measured React libraries ranking starts from the catalogue numbers; for the cost on the reader side read INP and responsiveness.
Frequently asked questions
Do I need a library to fetch in React?
No. A request per component needs fetch and an effect with AbortController; a library earns its place when you want shared cache, retry and revalidation already written.
Why does the request fire twice in development?
Because StrictMode mounts, unmounts and remounts the component: the first effect is cancelled and the second one sends the request. In production the effect runs once.
How do I cancel a request when the page changes?
Pass an AbortController through the signal option and call abort in the effect cleanup. The catch has to ignore the error whose name is AbortError.
Can I use async directly inside useEffect?
No. The function passed to useEffect cannot be async, because it would return a promise where cleanup belongs. Declare an async function inside the effect and call it.
Does fetch throw on a 404?
No. The promise resolves on 404 and 500 as well, so you check res.ok and throw your own error, or handle the error state in the then branch.