Every React developer has written this at least a dozen times:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
It works. But it’s duplicated across every component that touches an API. The fix is a custom hook that encapsulates this pattern once and exposes a clean interface everywhere.
The basic useAPI hook
import { useState, useEffect } from 'react';
interface UseAPIOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
url: string;
body?: unknown;
}
interface UseAPIResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAPI<T>({ method = 'GET', url, body }: UseAPIOptions): UseAPIResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(url, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: { 'Content-Type': 'application/json' },
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<T>;
})
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [method, url, JSON.stringify(body)]);
return { data, loading, error };
}
Using it in a component
interface User {
id: number;
name: string;
email: string;
}
function UserList() {
const { data, loading, error } = useAPI<User[]>({ url: '/api/users' });
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return null;
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
No boilerplate in the component. The three states — loading, error, data — are always available without repetition.
Supporting POST requests
The same hook handles mutations. For a form submission:
function CreatePost() {
const [submitted, setSubmitted] = useState(false);
const [formData, setFormData] = useState({ title: '', body: '' });
const { data, loading, error } = useAPI<{ id: number }>({
method: 'POST',
url: submitted ? '/api/posts' : '',
body: submitted ? formData : undefined,
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitted(true);
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.title}
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
placeholder="Title"
/>
<button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</button>
{error && <p style={{ color: 'red' }}>{error.message}</p>}
{data && <p>Created post #{data.id}</p>}
</form>
);
}
Adding abort support
The basic version has a bug: if the component unmounts while a request is in flight, the state update fires on an unmounted component. Fix it with AbortController:
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, {
method,
body: body ? JSON.stringify(body) : undefined,
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [method, url, JSON.stringify(body)]);
The cleanup function cancels the request when the component unmounts or the dependencies change — eliminating the race condition.
When to reach for something more
useAPI is a great fit for simple cases. As requirements grow, consider:
- TanStack Query — adds caching, background refetching, pagination, and mutations with a consistent API
- SWR — stale-while-revalidate strategy, minimal API, good for read-heavy apps
- RTK Query — if you’re already using Redux Toolkit, it’s a natural fit
These libraries solve the problems you’ll hit next: deduplication of concurrent requests, cache invalidation, optimistic updates, and retry logic. The custom hook approach is a good stepping stone to understanding why they exist.
Key takeaways
- Extract the fetch + state boilerplate into a single
useAPIhook and never write it again - TypeScript generics (
useAPI<User[]>) propagate the return type without extra casting - Always add
AbortControllercleanup to avoid state updates on unmounted components - Serialize the
bodyin theuseEffectdependency array to avoid infinite loops with object references - For production apps with complex data-fetching needs, TanStack Query is worth the dependency