//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { useState, useEffect, useCallback, useRef } from "react";
interface UseFetchOptions<T> {
/** Skip the initial fetch. Default: false */
skip?: boolean;
/** Transform the response before setting data */
transform?: (data: unknown) => T;
/** Custom fetch init options (headers, method, etc.) */
init?: RequestInit;
/** Dependencies that trigger a refetch when changed */
deps?: unknown[];
}
interface UseFetchReturn<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
/** Manually trigger a refetch */
refetch: () => void;
/** Abort the current request */
abort: () => void;
}
function useFetch<T = unknown>(
url: string | null,
options: UseFetchOptions<T> = {}
): UseFetchReturn<T> {
const { skip = false, transform, init, deps = [] } = options;
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(!skip && url !== null);
const abortControllerRef = useRef<AbortController | null>(null);
const mountedRef = useRef(true);
const fetchData = useCallback(async () => {
if (!url || skip) return;
// Abort any in-flight request
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
const result = transform ? transform(json) : (json as T);
if (mountedRef.current && !controller.signal.aborted) {
setData(result);
setIsLoading(false);
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Request was aborted, do not update state
return;
}
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
}
}
}, [url, skip, transform, init, ...deps]);
// Fetch on mount and when dependencies change
useEffect(() => {
mountedRef.current = true;
fetchData();
return () => {
mountedRef.current = false;
abortControllerRef.current?.abort();
};
}, [fetchData]);
const abort = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
return { data, error, isLoading, refetch: fetchData, abort };
}When to reach for this: You need a lightweight data-fetching hook for simple use cases, prototypes, or when SWR/TanStack Query is too heavy. For production apps with caching, deduplication, and revalidation, prefer a dedicated library.
"use client";
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, error, isLoading, refetch } = useFetch<User>(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ deps: [userId] }
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return null;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}
function PostList() {
const { data: posts, isLoading } = useFetch<
Array<{ id: number; title: string }>
>("https://jsonplaceholder.typicode.com/posts", {
transform: (raw) =>
(raw as Array<{ id: number; title: string }>).slice(0, 10),
});
if (isLoading) return <p>Loading posts...</p>;
return (
<ul>
{posts?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// Skip fetch until user acts
function SearchResults() {
const [query, setQuery] = useState("");
const [searchTerm, setSearchTerm] = useState<string | null>(null);
const { data, isLoading } = useFetch<{ results: string[] }>(
searchTerm
? `https://api.example.com/search?q=${encodeURIComponent(searchTerm)}`
: null
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button onClick={() => setSearchTerm(query)}>Search</button>
{isLoading && <p>Searching...</p>}
{data?.results.map((r, i) => <p key={i}>{r}</p>)}
</div>
);
}What this demonstrates:
userId changes, manual refetch buttonnull as URL to skip fetching until the user submits a searchAbortController. If the URL or dependencies change before the request completes, the previous request is aborted to prevent race conditions.null as the URL skips the fetch entirely. This is a common pattern for conditional fetching (similar to SWR's conditional fetching).setData, letting you reshape API responses (filter, map, pick fields) without extra state.mountedRef prevents state updates after unmount, avoiding React warnings.DOMException with name: "AbortError". The hook silently ignores these to avoid false error states.| Parameter | Type | Default | Description |
|---|---|---|---|
url | string or null | - | Fetch URL, or null to skip |
options.skip | boolean | false | Skip the fetch |
options.transform | (data: unknown) => T | - | Transform response data |
options.init | RequestInit | - | Fetch options (headers, method, body) |
options.deps | unknown[] | [] | Extra deps that trigger refetch |
| Return | Type | Description |
|---|---|---|
data | T or null | Response data, or null |
error | Error or null | Error, or null |
isLoading | boolean | Whether a request is in flight |
refetch | () => void | Manually trigger a new fetch |
abort | () => void | Cancel the current request |
POST/PUT requests: Pass method and body through init:
const { data, isLoading } = useFetch("/api/users", {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
},
});With retry: Add automatic retry on failure:
// Inside fetchData, wrap in a retry loop:
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, { signal: controller.signal });
// ...success handling
break;
} catch (err) {
if (attempt === maxRetries - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * Math.pow(2, attempt)));
}
}| Feature | useFetch (this hook) | SWR | TanStack Query |
|---|---|---|---|
| Bundle size | 0 KB | ~4 KB | ~13 KB |
| Caching | No | Yes | Yes |
| Deduplication | No | Yes | Yes |
| Revalidation | Manual only | Automatic | Automatic |
| Optimistic updates | No | Yes | Yes |
| DevTools | No | No | Yes |
| Best for | Prototypes, simple cases | Medium complexity | Complex apps |
T defaults to unknown and is inferred from the transform function if provided.data is typed as T | null (null before the first successful fetch).error is always Error | null, wrapping non-Error throws in new Error(String(err)).AbortController cancels stale requests; always use it.init object on every render triggers infinite refetches. Fix: Memoize init with useMemo or define it outside the component....deps spread in the dependency array can cause unexpected refetches if deps contain unstable references. Fix: Ensure deps contain only primitives or stable references.| Package | Hook Name | Notes |
|---|---|---|
swr | useSWR | Stale-while-revalidate, most popular |
@tanstack/react-query | useQuery | Full-featured, devtools, mutations |
react | use() | React 19 built-in for suspense-based fetching |
usehooks-ts | useFetch | Similar simple implementation |
axios + custom hook | - | Use axios for interceptors, transform with a wrapper hook |
fetchData creates a new AbortController and aborts the previous in-flight request.controller.signal.aborted check prevents stale responses from updating state.Either pass skip: true or pass null as the URL:
// Option 1: skip option
const { refetch } = useFetch("/api/data", { skip: true });
// Option 2: null URL
const { data } = useFetch(query ? `/api/search?q=${query}` : null);transform runs before setData, letting you reshape the API response (filter, map, pick fields) without extra state.Pass method and body through the init option:
const { data } = useFetch("/api/users", {
init: {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Alice" }),
},
});useCallback dependency, triggering infinite refetches.init with useMemo or define it as a constant outside the component.deps contains objects or arrays that are recreated each render, the reference changes trigger a new fetchData function, which triggers the fetch effect.DOMException with name: "AbortError".T defaults to unknown and can be explicitly provided: useFetch<User>(url).transform function is provided, T is inferred from its return type.data is typed as T | null, where null represents the pre-fetch state.error is typed as Error | null.new Error(String(err)) to normalize the type.Use the abort function from the return value:
const { data, isLoading, abort } = useFetch("/api/slow-endpoint");
return <button onClick={abort}>Cancel</button>;Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥