Fetching Data in an Effect listed the four things a hand-written data Effect has to get right: a loading flag, an error path, cancellation on unmount, and protection against out-of-order responses. Every fetching component needs all four, which makes this the most rewarding Hook here to extract.
import { useEffect, useState } from 'react';
export function useFetch(url, { headers } = {}) {
const [state, setState] = useState({ data: null, error: null, loading: true });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, error: null, loading: true });
fetch(url, { headers, signal: controller.signal })
.then(res => res.ok ? res.json()
: Promise.reject(new Error(`HTTP ${res.status} ${res.statusText}`)))
.then(data => setState({ data, error: null, loading: false }))
.catch(error => error.name === 'AbortError' ? null
: setState({ data: null, error, loading: false }));
return () => controller.abort();
}, [url, JSON.stringify(headers)]);
return state;
}A caller destructures the three fields and branches on loading, then error, then renders data. Three details carry the weight. The cleanup aborts the request, so a component that unmounts mid-flight neither leaks nor sets state on a dead fiber; since an aborted fetch rejects with an AbortError, that name is filtered out rather than shown to the user. Abort also settles out-of-order responses: when url changes React 7,897 runs the previous cleanup first, so a stale request can never win the race. And the three fields are set together in one object, making an impossible state such as "loading and error at once" unrepresentable.
The headers object is serialized in the dependency array because a fresh object literal each render would restart the request forever. That workaround hints at the Hook's ceiling: no cache, no deduplication, no retry, no refetch on focus. Once several components want the same URL, move to TanStack Query 5.103.1 79,069 (npm 2,036 install @tanstack/react-query), or let a framework's router load the data. React 19 also lets you hand a cached promise to the use Hook (The use Hook) and let Suspense show the loading state.