useFetch — Simple data fetching with loading, error, and refetch
Recipe
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.
Working Example
"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:
- UserProfile: Fetches user data by ID, refetches when
userIdchanges, manual refetch button - PostList: Transforms the response to take only the first 10 items
- SearchResults: Passes
nullas URL to skip fetching until the user submits a search
Deep Dive
How It Works
- AbortController: Every fetch creates a new
AbortController. If the URL or dependencies change before the request completes, the previous request is aborted to prevent race conditions. - Null URL pattern: Passing
nullas the URL skips the fetch entirely. This is a common pattern for conditional fetching (similar to SWR's conditional fetching). - Transform function: Runs before
setData, letting you reshape API responses (filter, map, pick fields) without extra state. - Mount guard:
mountedRefprevents state updates after unmount, avoiding React warnings. - AbortError handling: Aborted requests throw a
DOMExceptionwithname: "AbortError". The hook silently ignores these to avoid false error states.
Parameters & Return Values
| 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 |
Variations
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)));
}
}When to Use This vs Libraries
| 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 |
TypeScript Notes
- Generic
Tdefaults tounknownand is inferred from thetransformfunction if provided. datais typed asT | null(null before the first successful fetch).erroris alwaysError | null, wrapping non-Error throws innew Error(String(err)).
Gotchas
- Race conditions without abort — If the URL changes rapidly, multiple requests may resolve out of order. Fix: The
AbortControllercancels stale requests; always use it. - No caching — Every mount triggers a new network request. Fix: For apps that need caching, use SWR or TanStack Query.
- init reference instability — Passing a new
initobject on every render triggers infinite refetches. Fix: MemoizeinitwithuseMemoor define it outside the component. - deps spread in useCallback — The
...depsspread in the dependency array can cause unexpected refetches if deps contain unstable references. Fix: Ensure deps contain only primitives or stable references. - No SSR data — This hook only fetches on the client. Fix: For SSR/RSC, fetch data in a server component and pass it as props.
Alternatives
| 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 |
FAQs
How does useFetch prevent race conditions when the URL changes rapidly?
- Each call to
fetchDatacreates a newAbortControllerand aborts the previous in-flight request. - The
controller.signal.abortedcheck prevents stale responses from updating state.
How do you skip the initial fetch and trigger it manually?
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);What does the transform option do and when would you use it?
transformruns beforesetData, letting you reshape the API response (filter, map, pick fields) without extra state.- Example: slicing a large array to only the first 10 items.
How do you make a POST request with useFetch?
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" }),
},
});Gotcha: What happens if you pass a new init object on every render?
- A new object reference on each render changes the
useCallbackdependency, triggering infinite refetches. - Fix: memoize
initwithuseMemoor define it as a constant outside the component.
Gotcha: Why does spreading deps in the useCallback dependency array sometimes cause unexpected refetches?
- If
depscontains objects or arrays that are recreated each render, the reference changes trigger a newfetchDatafunction, which triggers the fetch effect. - Fix: ensure deps contain only primitives or stable references.
Why does useFetch not provide caching, and when should you switch to a library?
- Every mount triggers a new network request with no deduplication or stale-while-revalidate logic.
- Switch to SWR or TanStack Query when you need caching, automatic revalidation, optimistic updates, or devtools.
How does the hook handle AbortError differently from other errors?
- Aborted requests throw a
DOMExceptionwithname: "AbortError". - The hook detects this and returns early without updating error state, since the abort was intentional.
Does useFetch work with server-side rendering?
- No. This hook only fetches on the client after mount.
- For SSR or React Server Components, fetch data in a server component and pass it as props.
How is the generic type T inferred in TypeScript?
Tdefaults tounknownand can be explicitly provided:useFetch<User>(url).- If a
transformfunction is provided,Tis inferred from its return type. datais typed asT | null, wherenullrepresents the pre-fetch state.
What is the type of the error returned by useFetch?
erroris typed asError | null.- Non-Error throws are wrapped in
new Error(String(err))to normalize the type.
How do you abort a request manually from the consuming component?
Use the abort function from the return value:
const { data, isLoading, abort } = useFetch("/api/slow-endpoint");
return <button onClick={abort}>Cancel</button>;Related
- useDebounce — debounce search queries before fetching
- useInterval — poll an endpoint periodically
- SWR — production-grade data fetching