The pattern everyone writes once and then copies forever
Fetching JSON in a React component always needs the same three pieces of state — the data itself, a loading flag, and an error — and almost every codebase ends up with a slightly different hand-rolled version of the same useEffect block. Getting the shared version right once, as a hook, is worth doing before it's copy-pasted into a dozen components with a dozen small inconsistencies.
The naive version, and what's wrong with it
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]);
return user ? <div>{user.name}</div> : <p>Loading...</p>;
}This works for a demo and breaks in a few specific, common ways: it never checks res.ok, so a 404 or 500 response still gets JSON-parsed as if it succeeded (or throws a confusing parse error on a non-JSON error page); there's no error state to render; and if userId changes quickly (fast navigation, a search box), an earlier slow request can resolve *after* a newer one and overwrite fresher data with stale data — a real, hard-to-reproduce bug called a race condition.
A typed, reusable version
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
})
.then((data) => setState({ data, loading: false, error: null }))
.catch((err) => {
if (err.name === "AbortError") return; // request was cancelled, not a real error
setState({ data: null, loading: false, error: err.message });
});
return () => controller.abort(); // cancels the in-flight request on unmount or re-run
}, [url]);
return state;
}The AbortController is what actually fixes the race condition — when url changes and the effect re-runs, the cleanup function cancels the previous request, so a slow, now-stale response never reaches setState at all instead of needing a manual "is this still the latest request" flag.
interface User { id: string; name: string; email: string; }
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Failed to load user: {error}</p>;
return <div>{user!.name}</div>;
}Where should the User type come from?
Hand-writing interface User { ... } and hoping it matches the real API response is a common source of drift — the type says one thing, the backend ships another, and nothing catches the mismatch until runtime. Two better sources: generate the interface directly from a real sample response with JSON to TypeScript, or — if the backend publishes an OpenAPI spec — generate types from that spec with a tool like openapi-typescript, which keeps the frontend types tied to the documented contract rather than to whatever sample happened to be pasted in once.
Handling an array response
The same hook works unchanged for a list endpoint — only the generic type parameter and the loading UI differ:
const { data: users, loading, error } = useFetch<User[]>("/api/users");
if (loading) return <Skeleton count={5} />;
if (error) return <ErrorBanner message={error} />;
return <ul>{users!.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;When to stop hand-rolling this and use a library
The hook above covers the common case, but a real application usually grows requirements a hand-rolled hook doesn't cleanly cover — caching between components that both fetch the same URL, automatic background refetching, retry-with-backoff on failure, request deduplication. That's the point to reach for TanStack Query (React Query) or SWR rather than continuing to extend a homegrown hook — both solve exactly this set of problems and are the de facto standard for anything beyond a handful of simple fetches.
// TanStack Query — same shape of result, considerably more capability underneath
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});