Building a Custom useFetch Hook: Data Fetching Patterns
Data fetching is one of the most fundamental tasks in modern web applications. Yet it's also surprisingly complex. You need to handle loading states, errors, retries, caching, request cancellation, and race conditions—all while keeping your component code clean. That's where a well-designed custom useFetch hook becomes invaluable.
In this comprehensive guide, you'll learn how to build a production-ready useFetch hook from scratch. We'll explore different patterns, from a simple implementation to an advanced version with caching and cancellation support. By the end, you'll understand not just how to build one, but when and why to use custom hooks for data fetching instead of relying solely on libraries.
Table of Contents
- Why Build a Custom Hook?
- Basic Implementation
- Adding Error Handling
- Managing Loading States
- Request Cancellation
- Caching Strategy
- Advanced Patterns
- Common Pitfalls
- FAQ
Why Build a Custom Hook?
Before diving into code, let's establish when a custom useFetch hook makes sense.
The Problem with Inline Fetch
Most beginners start with useEffect and fetch directly in components:
// ❌ Anti-pattern: fetch logic mixed with component logic
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (isMounted) setUser(data);
})
.catch(err => {
if (isMounted) setError(err);
})
.finally(() => {
if (isMounted) setLoading(false);
});
return () => {
isMounted = false;
};
}, [userId]);
// Rest of component...
}
This works, but it's verbose, repetitive, and error-prone. Every component that fetches data repeats this pattern. A custom hook extracts this logic and makes it reusable.
Benefits of useFetch Hook
- Reusability: Write once, use in many components
- Consistency: Same error handling, loading states everywhere
- Testability: Hook logic is separate from component logic
- Maintainability: Fix bugs in one place
- Clarity: Component code focuses on UI, not fetch mechanics
Basic Implementation
Let's start with the simplest working version.
TypeScript Version
import { useState, useEffect } from 'react';
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(url: string): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
// Prevent race conditions
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (isMounted) {
setState({ data: json, loading: false, error: null });
}
} catch (err) {
if (isMounted) {
setState({
data: null,
loading: false,
error: err instanceof Error ? err : new Error('Unknown error')
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return state;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (isMounted) {
setState({ data: json, loading: false, error: null });
}
} catch (err) {
if (isMounted) {
setState({
data: null,
loading: false,
error: err instanceof Error ? err : new Error('Unknown error')
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return state;
}
Usage:
function UserList() {
const { data: users, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
The isMounted flag prevents the notorious "can't perform a React state update on an unmounted component" warning.
Adding Error Handling
Basic error handling is good, but production apps need more sophisticated approaches.
TypeScript Version
import { useState, useEffect, useCallback } from 'react';
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
statusCode: number | null;
}
interface UseFetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: Record<string, unknown>;
shouldRetry?: boolean;
maxRetries?: number;
}
export function useFetch<T>(
url: string,
options?: UseFetchOptions
): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null,
statusCode: null
});
const {
method = 'GET',
headers = {},
body,
shouldRetry = true,
maxRetries = 3
} = options || {};
useEffect(() => {
let isMounted = true;
let retryCount = 0;
const fetchData = async () => {
try {
const config: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...headers
}
};
if (body) {
config.body = JSON.stringify(body);
}
const response = await fetch(url, config);
if (!response.ok) {
// Retry on 5xx errors
if (
shouldRetry &&
retryCount < maxRetries &&
response.status >= 500
) {
retryCount++;
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, retryCount) * 1000)
);
return fetchData();
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (isMounted) {
setState({
data: json,
loading: false,
error: null,
statusCode: response.status
});
}
} catch (err) {
if (isMounted) {
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
loading: false,
error,
statusCode: null
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url, method, JSON.stringify(body), JSON.stringify(headers), shouldRetry, maxRetries]);
return state;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useFetch(url, options = {}) {
const [state, setState] = useState({
data: null,
loading: true,
error: null,
statusCode: null
});
const {
method = 'GET',
headers = {},
body,
shouldRetry = true,
maxRetries = 3
} = options;
useEffect(() => {
let isMounted = true;
let retryCount = 0;
const fetchData = async () => {
try {
const config = {
method,
headers: {
'Content-Type': 'application/json',
...headers
}
};
if (body) {
config.body = JSON.stringify(body);
}
const response = await fetch(url, config);
if (!response.ok) {
if (
shouldRetry &&
retryCount < maxRetries &&
response.status >= 500
) {
retryCount++;
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, retryCount) * 1000)
);
return fetchData();
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (isMounted) {
setState({
data: json,
loading: false,
error: null,
statusCode: response.status
});
}
} catch (err) {
if (isMounted) {
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
loading: false,
error,
statusCode: null
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url, method, body, headers, shouldRetry, maxRetries]);
return state;
}
Key Features:
- HTTP error detection
- Automatic retry with exponential backoff
- Configurable headers and request body
- Status code tracking
Managing Loading States
More granular loading states help create better UX:
TypeScript Version
import { useState, useEffect } from 'react';
type FetchStatus = 'idle' | 'loading' | 'success' | 'error';
interface UseFetchState<T> {
data: T | null;
status: FetchStatus;
error: Error | null;
isLoading: boolean;
isError: boolean;
isSuccess: boolean;
}
export function useFetch<T>(
url: string | null
): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
status: 'idle',
error: null,
isLoading: false,
isError: false,
isSuccess: false
});
useEffect(() => {
// Don't fetch if URL is null (useful for conditional fetching)
if (!url) {
setState({
data: null,
status: 'idle',
error: null,
isLoading: false,
isError: false,
isSuccess: false
});
return;
}
let isMounted = true;
const fetchData = async () => {
setState(prev => ({
...prev,
status: 'loading',
isLoading: true
}));
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (isMounted) {
setState({
data: json,
status: 'success',
error: null,
isLoading: false,
isError: false,
isSuccess: true
});
}
} catch (err) {
if (isMounted) {
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
status: 'error',
error,
isLoading: false,
isError: true,
isSuccess: false
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return state;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [state, setState] = useState({
data: null,
status: 'idle',
error: null,
isLoading: false,
isError: false,
isSuccess: false
});
useEffect(() => {
if (!url) {
setState({
data: null,
status: 'idle',
error: null,
isLoading: false,
isError: false,
isSuccess: false
});
return;
}
let isMounted = true;
const fetchData = async () => {
setState(prev => ({
...prev,
status: 'loading',
isLoading: true
}));
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (isMounted) {
setState({
data: json,
status: 'success',
error: null,
isLoading: false,
isError: false,
isSuccess: true
});
}
} catch (err) {
if (isMounted) {
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
status: 'error',
error,
isLoading: false,
isError: true,
isSuccess: false
});
}
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return state;
}
Usage:
function DataList() {
const { data, status, isLoading, isError, error } = useFetch('/api/data');
return (
<div>
{status === 'idle' && <p>开始加载...</p>}
{isLoading && <p>加载中...</p>}
{isError && <p>错误: {error?.message}</p>}
{status === 'success' && (
<ul>
{data?.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)}
</div>
);
}
Request Cancellation
AbortController allows canceling in-flight requests when components unmount:
TypeScript Version
import { useState, useEffect } from 'react';
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(url: string): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
// Create AbortController for this fetch
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
setState({
data: json,
loading: false,
error: null
});
} catch (err) {
// Don't update state if request was aborted
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
loading: false,
error
});
}
};
fetchData();
// Cleanup: abort the request when component unmounts
return () => {
controller.abort();
};
}, [url]);
return state;
}
JavaScript Version
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
useEffect(() => {
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
setState({
data: json,
loading: false,
error: null
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
const error = err instanceof Error ? err : new Error('Unknown error');
setState({
data: null,
loading: false,
error
});
}
};
fetchData();
return () => {
controller.abort();
};
}, [url]);
return state;
}
AbortController is cleaner than the isMounted flag and is the modern standard.
Caching Strategy
Caching prevents redundant requests and improves performance:
TypeScript Version
import { useState, useEffect } from 'react';
interface CacheEntry<T> {
data: T;
timestamp: number;
}
interface UseFetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
// Simple in-memory cache
const cache = new Map<string, CacheEntry<unknown>>();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export function useFetch<T>(
url: string,
{ skipCache = false }: { skipCache?: boolean } = {}
): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null
});
useEffect(() => {
// Check cache first
if (!skipCache && cache.has(url)) {
const entry = cache.get(url) as CacheEntry<T>;
const isExpired = Date.now() - entry.timestamp > CACHE_DURATION;
if (!isExpired) {
setState({ data: entry.data, loading: false, error: null });
return;
}
}
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
// Store in cache
cache.set(url, { data: json, timestamp: Date.now() });
setState({ data: json, loading: false, error: null });
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
const error = err instanceof Error ? err : new Error('Unknown error');
setState({ data: null, loading: false, error });
}
};
fetchData();
return () => {
controller.abort();
};
}, [url, skipCache]);
return state;
}
JavaScript Version
import { useState, useEffect } from 'react';
const cache = new Map();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export function useFetch(url, { skipCache = false } = {}) {
const [state, setState] = useState({
data: null,
loading: true,
error: null
});
useEffect(() => {
if (!skipCache && cache.has(url)) {
const entry = cache.get(url);
const isExpired = Date.now() - entry.timestamp > CACHE_DURATION;
if (!isExpired) {
setState({ data: entry.data, loading: false, error: null });
return;
}
}
const controller = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
cache.set(url, { data: json, timestamp: Date.now() });
setState({ data: json, loading: false, error: null });
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
const error = err instanceof Error ? err : new Error('Unknown error');
setState({ data: null, loading: false, error });
}
};
fetchData();
return () => {
controller.abort();
};
}, [url, skipCache]);
return state;
}
Advanced Patterns
Pattern 1: Manual Refetch
Allow components to manually trigger refetches:
interface UseFetchReturn<T> extends UseFetchState<T> {
refetch: () => Promise<void>;
}
export function useFetch<T>(url: string): UseFetchReturn<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: true,
error: null
});
const refetch = async () => {
setState(prev => ({ ...prev, loading: true }));
// Fetch logic...
};
return { ...state, refetch };
}
Pattern 2: Conditional Fetching
Allow skipping fetch when URL is null:
export function useFetch<T>(
url: string | null
): UseFetchState<T> {
const [state, setState] = useState<UseFetchState<T>>({
data: null,
loading: !!url, // Only loading if URL is provided
error: null
});
useEffect(() => {
if (!url) return;
// Fetch logic...
}, [url]);
return state;
}
Pattern 3: Transform Data
Apply transformations to fetched data:
export function useFetch<T, R>(
url: string,
transform?: (data: T) => R
): UseFetchState<R> {
// Fetch logic...
const transformedData = transform ? transform(json) : json;
// Set state with transformed data
}
Common Pitfalls
Pitfall 1: Missing Dependency Array
// ❌ Wrong - fetches on every render
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}); // No dependency array!
// ✅ Correct
useEffect(() => {
fetch(url).then(res => res.json()).then(setData);
}, [url]); // Dependency array included
Pitfall 2: Comparing Objects in Dependencies
// ❌ Bad - creates new object each render
const options = { method: 'POST' };
useFetch(url, options);
// ✅ Better - move outside or use useMemo
const options = useMemo(() => ({ method: 'POST' }), []);
useFetch(url, options);
Pitfall 3: Not Handling Race Conditions
// ❌ Bad - can set stale data if requests complete out of order
useEffect(() => {
fetch(url).then(res => setState(res));
}, [url]);
// ✅ Good - uses AbortController
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, [url]);
Pitfall 4: Infinite Loops with Objects
// ❌ Infinite loop - new object each render
useFetch(url, { headers: { Authorization: token } });
// ✅ Fixed - memoize options
const options = useMemo(
() => ({ headers: { Authorization: token } }),
[token]
);
useFetch(url, options);
FAQ
Q: Should I use useFetch or a library like React Query?
A: For simple apps, useFetch is fine. For production apps with complex caching, background refetching, and synchronization needs, React Query/TanStack Query is better. Start with useFetch and upgrade if needed.
Q: Can I use useFetch with GraphQL?
A: Yes, just change the fetch call:
export function useGraphQL<T>(query: string): UseFetchState<T> {
// Same pattern, but POST to GraphQL endpoint
return useFetch(
'/graphql',
{ method: 'POST', body: { query } }
);
}
Q: How do I handle POST requests with useFetch?
A: Pass method and body options:
const { data, error, loading } = useFetch(url, {
method: 'POST',
body: { name: 'John', email: 'john@example.com' }
});
Q: Is AbortController supported in older browsers?
A: It's supported in modern browsers (>95% usage). For IE11 support, fall back to isMounted flag or use a polyfill.
Q: How do I cache data globally across components?
A: Use Context API or a state management library like Zustand. Or upgrade to React Query for built-in global caching.
Q: Can I use useFetch in Server Components?
A: No, hooks only work in Client Components. For Server Components, use native fetch directly.
Q: How do I handle authentication with useFetch?
A: Add headers to requests:
const authToken = useAuth();
const { data } = useFetch(url, {
headers: {
Authorization: `Bearer ${authToken}`
}
});
Practical Scenario: Chinese E-commerce App
In a typical Chinese e-commerce app (like Alibaba or JD.com), you need to fetch product listings, filter by category, and handle pagination. Here's how useFetch integrates:
TypeScript Version
interface Product {
id: string;
name: string;
price: number;
image: string;
}
interface FetchProductsParams {
category: string;
page: number;
perPage?: number;
}
export function useProducts({ category, page, perPage = 20 }: FetchProductsParams) {
const queryParams = new URLSearchParams({
category,
page: page.toString(),
perPage: perPage.toString()
});
const url = `/api/products?${queryParams}`;
return useFetch<Product[]>(url);
}
// In component
function ProductList({ category }: { category: string }) {
const [page, setPage] = useState(1);
const { data: products, loading, error } = useProducts({ category, page });
if (loading) return <div>加载商品中...</div>;
if (error) return <div>加载失败: {error.message}</div>;
return (
<div>
<div className="products">
{products?.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>¥{product.price}</p>
</div>
))}
</div>
<button onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
);
}
JavaScript Version
export function useProducts({ category, page, perPage = 20 }) {
const queryParams = new URLSearchParams({
category,
page: page.toString(),
perPage: perPage.toString()
});
const url = `/api/products?${queryParams}`;
return useFetch(url);
}
function ProductList({ category }) {
const [page, setPage] = useState(1);
const { data: products, loading, error } = useProducts({ category, page });
if (loading) return <div>加载商品中...</div>;
if (error) return <div>加载失败: {error.message}</div>;
return (
<div>
<div className="products">
{products?.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>¥{product.price}</p>
</div>
))}
</div>
<button onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
);
}
Related Articles:
- useEffect Hook: Understanding Side Effects in React
- useReducer for Complex State Management
- Error Boundaries: Handling Errors Gracefully
- Request Caching and Performance Optimization
Questions? Have you built a custom useFetch hook before? What features did you find most useful? Or are you considering migrating to React Query? Share your experience in the comments below!
Google AdSense Placeholder
CONTENT SLOT