How to Build a Custom React Hook for API Calls
Introduction
API calls are fundamental to modern web applications, but managing loading states, error handling, and data caching can quickly become repetitive and error-prone. Custom React hooks provide an elegant solution to encapsulate this logic and create reusable, maintainable code.
In this comprehensive guide, we'll build a robust custom hook for API calls that handles:
- Loading states
- Error management
- Request cancellation
- Caching mechanisms
- Retry logic
- Type safety with TypeScript
By the end of this tutorial, you'll have a production-ready hook that simplifies API management across your React applications.
Understanding Custom Hooks
What Are Custom Hooks?
Custom hooks are JavaScript functions that:
- Start with the prefix "use"
- Can call other hooks
- Allow you to extract component logic into reusable functions
- Follow the Rules of Hooks
// Basic custom hook structure
function useCustomHook() {
// Hook logic here
return /* hook return value */;
}
Why Build a Custom API Hook?
Traditional API calls in React often look like this:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
setUser(userData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
This pattern repeats across components, leading to code duplication and maintenance challenges.
Building the Basic useApi Hook
Let's start with a simple version and progressively enhance it:
Step 1: Basic Hook Structure
import { useState, useEffect } from 'react';
function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (url) {
fetchData();
}
}, [url]);
return { data, loading, error };
}
Step 2: Using the Basic Hook
function UserProfile({ userId }) {
const { data: user, loading, error } = useApi(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
Much cleaner! But we can make it even better.
Enhanced useApi Hook with Advanced Features
Step 3: Adding Request Cancellation
import { useState, useEffect, useRef } from 'react';
function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
useEffect(() => {
const fetchData = async () => {
try {
// Cancel previous request if it exists
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// Create new AbortController for this request
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setLoading(true);
setError(null);
const response = await fetch(url, {
...options,
signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// Only update state if request wasn't cancelled
if (!signal.aborted) {
setData(result);
}
} catch (err) {
// Don't set error for aborted requests
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
};
if (url) {
fetchData();
}
// Cleanup: abort request on unmount or dependency change
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url, JSON.stringify(options)]);
return { data, loading, error };
}
Step 4: Adding Manual Refetch Capability
function useApi(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
const fetchData = async (fetchUrl = url, fetchOptions = options) => {
try {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setLoading(true);
setError(null);
const response = await fetch(fetchUrl, {
...fetchOptions,
signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (!signal.aborted) {
setData(result);
}
return result;
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
throw err;
}
} finally {
setLoading(false);
}
};
useEffect(() => {
if (url) {
fetchData();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url, JSON.stringify(options)]);
const refetch = () => fetchData();
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
return { data, loading, error, refetch, cancel };
}
Adding TypeScript Support
Let's make our hook type-safe:
import { useState, useEffect, useRef } from 'react';
interface ApiState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
interface UseApiOptions extends RequestInit {
skip?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: string) => void;
}
interface UseApiReturn<T> extends ApiState<T> {
refetch: () => Promise<T | undefined>;
cancel: () => void;
}
function useApi<T = any>(
url: string | null,
options: UseApiOptions = {}
): UseApiReturn<T> {
const { skip = false, onSuccess, onError, ...fetchOptions } = options;
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: false,
error: null,
});
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = async (
fetchUrl: string = url!,
customOptions: RequestInit = {}
): Promise<T | undefined> => {
try {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setState((prev) => ({ ...prev, loading: true, error: null }));
const response = await fetch(fetchUrl, {
...fetchOptions,
...customOptions,
signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: T = await response.json();
if (!signal.aborted) {
setState((prev) => ({ ...prev, data: result, loading: false }));
onSuccess?.(result);
}
return result;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
if (err instanceof Error && err.name !== 'AbortError') {
setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
onError?.(errorMessage);
throw err;
}
}
};
useEffect(() => {
if (url && !skip) {
fetchData();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url, skip, JSON.stringify(fetchOptions)]);
const refetch = () => fetchData();
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
return {
...state,
refetch,
cancel,
};
}
Advanced Features: Caching and Retry Logic
Adding Simple Caching
interface CacheEntry<T> {
data: T;
timestamp: number;
expiresIn: number;
}
class ApiCache {
private cache = new Map<string, CacheEntry<any>>();
set<T>(key: string, data: T, expiresIn: number = 5 * 60 * 1000): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
expiresIn,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
const isExpired = Date.now() - entry.timestamp > entry.expiresIn;
if (isExpired) {
this.cache.delete(key);
return null;
}
return entry.data;
}
clear(): void {
this.cache.clear();
}
delete(key: string): void {
this.cache.delete(key);
}
}
const apiCache = new ApiCache();
// Enhanced hook with caching
function useApi<T = any>(
url: string | null,
options: UseApiOptions & {
cacheKey?: string;
cacheTime?: number;
staleWhileRevalidate?: boolean;
} = {}
): UseApiReturn<T> {
const {
skip = false,
onSuccess,
onError,
cacheKey,
cacheTime = 5 * 60 * 1000, // 5 minutes default
staleWhileRevalidate = false,
...fetchOptions
} = options;
const effectiveCacheKey = cacheKey || url;
const [state, setState] = useState<ApiState<T>>(() => {
// Try to get cached data on initialization
if (effectiveCacheKey) {
const cachedData = apiCache.get<T>(effectiveCacheKey);
if (cachedData) {
return {
data: cachedData,
loading: false,
error: null,
};
}
}
return {
data: null,
loading: false,
error: null,
};
});
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = async (
fetchUrl: string = url!,
customOptions: RequestInit = {},
skipCache: boolean = false
): Promise<T | undefined> => {
// Check cache first (unless skipCache is true)
if (!skipCache && effectiveCacheKey) {
const cachedData = apiCache.get<T>(effectiveCacheKey);
if (cachedData) {
if (!staleWhileRevalidate) {
setState((prev) => ({ ...prev, data: cachedData, loading: false }));
return cachedData;
} else {
// Return stale data immediately, but continue to fetch fresh data
setState((prev) => ({ ...prev, data: cachedData }));
}
}
}
try {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
setState((prev) => ({ ...prev, loading: true, error: null }));
const response = await fetch(fetchUrl, {
...fetchOptions,
...customOptions,
signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result: T = await response.json();
if (!signal.aborted) {
setState((prev) => ({ ...prev, data: result, loading: false }));
// Cache the result
if (effectiveCacheKey) {
apiCache.set(effectiveCacheKey, result, cacheTime);
}
onSuccess?.(result);
}
return result;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
if (err instanceof Error && err.name !== 'AbortError') {
setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
onError?.(errorMessage);
throw err;
}
}
};
useEffect(() => {
if (url && !skip) {
fetchData();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url, skip, effectiveCacheKey, JSON.stringify(fetchOptions)]);
const refetch = (skipCache: boolean = true) => fetchData(url!, {}, skipCache);
const cancel = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
return {
...state,
refetch,
cancel,
};
}
Adding Retry Logic
interface UseApiOptions extends RequestInit {
skip?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: string) => void;
cacheKey?: string;
cacheTime?: number;
staleWhileRevalidate?: boolean;
retries?: number;
retryDelay?: number;
retryCondition?: (error: Error, attempt: number) => boolean;
}
function useApi<T = any>(
url: string | null,
options: UseApiOptions = {}
): UseApiReturn<T> {
const {
skip = false,
onSuccess,
onError,
cacheKey,
cacheTime = 5 * 60 * 1000,
staleWhileRevalidate = false,
retries = 0,
retryDelay = 1000,
retryCondition = (error: Error, attempt: number) =>
attempt <= retries && !error.message.includes('404'),
...fetchOptions
} = options;
// ... previous state and cache logic ...
const fetchDataWithRetry = async (
fetchUrl: string = url!,
customOptions: RequestInit = {},
skipCache: boolean = false,
attempt: number = 0
): Promise<T | undefined> => {
try {
return await fetchData(fetchUrl, customOptions, skipCache);
} catch (error) {
const err = error as Error;
if (retryCondition(err, attempt) && attempt < retries) {
// Wait before retrying
await new Promise((resolve) =>
setTimeout(resolve, retryDelay * Math.pow(2, attempt))
);
return fetchDataWithRetry(
fetchUrl,
customOptions,
skipCache,
attempt + 1
);
}
throw error;
}
};
// Update useEffect to use retry logic
useEffect(() => {
if (url && !skip) {
fetchDataWithRetry();
}
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [url, skip, effectiveCacheKey, JSON.stringify(fetchOptions)]);
const refetch = (skipCache: boolean = true) =>
fetchDataWithRetry(url!, {}, skipCache, 0);
// ... rest of the hook ...
}
Real-World Usage Examples
Example 1: User Profile Component
interface User {
id: number;
name: string;
email: string;
avatar: string;
}
function UserProfile({ userId }: { userId: number }) {
const {
data: user,
loading,
error,
refetch,
} = useApi<User>(`/api/users/${userId}`, {
cacheTime: 10 * 60 * 1000, // Cache for 10 minutes
retries: 2,
onError: (error) => {
console.error('Failed to fetch user:', error);
},
});
if (loading) return <UserSkeleton />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return (
<div className="user-profile">
<img src={user?.avatar} alt={user?.name} />
<h2>{user?.name}</h2>
<p>{user?.email}</p>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
Example 2: Posts List with Pagination
interface Post {
id: number;
title: string;
content: string;
author: string;
createdAt: string;
}
interface PostsResponse {
posts: Post[];
pagination: {
page: number;
totalPages: number;
totalItems: number;
};
}
function PostsList() {
const [page, setPage] = useState(1);
const [allPosts, setAllPosts] = useState<Post[]>([]);
const {
data: response,
loading,
error,
} = useApi<PostsResponse>(`/api/posts?page=${page}&limit=10`, {
cacheKey: `posts-page-${page}`,
cacheTime: 2 * 60 * 1000, // 2 minutes cache
onSuccess: (data) => {
if (page === 1) {
setAllPosts(data.posts);
} else {
setAllPosts((prev) => [...prev, ...data.posts]);
}
},
});
const loadMore = () => {
if (response && page < response.pagination.totalPages) {
setPage((prev) => prev + 1);
}
};
return (
<div className="posts-list">
{allPosts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{loading && <PostsSkeleton />}
{error && <ErrorMessage error={error} />}
{response && page < response.pagination.totalPages && (
<button onClick={loadMore} disabled={loading}>
Load More
</button>
)}
</div>
);
}
Example 3: Search with Debouncing
import { useMemo } from 'react';
import { useDebounce } from './useDebounce'; // Custom debounce hook
interface SearchResult {
id: number;
title: string;
description: string;
type: string;
}
function SearchComponent() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const searchUrl = useMemo(() => {
return debouncedQuery.trim()
? `/api/search?q=${encodeURIComponent(debouncedQuery)}`
: null;
}, [debouncedQuery]);
const {
data: results,
loading,
error,
} = useApi<SearchResult[]>(searchUrl, {
skip: !debouncedQuery.trim(),
cacheKey: `search-${debouncedQuery}`,
cacheTime: 30 * 1000, // 30 seconds cache for search results
});
return (
<div className="search-component">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Searching...</div>}
{error && <div>Search error: {error}</div>}
{results && (
<div className="search-results">
{results.map((result) => (
<SearchResultItem key={result.id} result={result} />
))}
</div>
)}
</div>
);
}
// Simple debounce hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
Testing Your Custom Hook
Testing custom hooks requires special consideration. Here's how to test our useApi
hook:
Setting Up Tests
npm install --save-dev @testing-library/react-hooks @testing-library/jest-dom
Basic Hook Tests
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from './useApi';
// Mock fetch
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'John Doe' };
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useApi('/api/users/1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
it('should handle errors correctly', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
});
const { result } = renderHook(() => useApi('/api/users/999'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error).toBe('HTTP error! status: 404');
});
it('should refetch data when refetch is called', async () => {
const mockData1 = { id: 1, name: 'John' };
const mockData2 = { id: 1, name: 'John Updated' };
(fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
json: async () => mockData1,
})
.mockResolvedValueOnce({
ok: true,
json: async () => mockData2,
});
const { result } = renderHook(() => useApi('/api/users/1'));
await waitFor(() => {
expect(result.current.data).toEqual(mockData1);
});
result.current.refetch();
await waitFor(() => {
expect(result.current.data).toEqual(mockData2);
});
});
});
Performance Considerations
Memory Management
// Add cleanup for cache to prevent memory leaks
class ApiCache {
private cache = new Map<string, CacheEntry<any>>();
private timers = new Map<string, NodeJS.Timeout>();
set<T>(key: string, data: T, expiresIn: number = 5 * 60 * 1000): void {
// Clear existing timer
const existingTimer = this.timers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set cache entry
this.cache.set(key, {
data,
timestamp: Date.now(),
expiresIn,
});
// Set cleanup timer
const timer = setTimeout(() => {
this.cache.delete(key);
this.timers.delete(key);
}, expiresIn);
this.timers.set(key, timer);
}
clear(): void {
// Clear all timers
this.timers.forEach((timer) => clearTimeout(timer));
this.timers.clear();
this.cache.clear();
}
}
Preventing Unnecessary Renders
import { useMemo, useCallback } from 'react';
function useApi<T = any>(
url: string | null,
options: UseApiOptions = {}
): UseApiReturn<T> {
// Memoize the return object to prevent unnecessary renders
return useMemo(
() => ({
...state,
refetch: useCallback(
(skipCache: boolean = true) =>
fetchDataWithRetry(url!, {}, skipCache, 0),
[url]
),
cancel: useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []),
}),
[state, url]
);
}
Best Practices and Tips
1. Error Boundary Integration
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }: any) {
return (
<div role="alert" className="error-fallback">
<h2>Something went wrong:</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => window.location.reload()}
>
<UserProfile userId={1} />
</ErrorBoundary>
);
}
2. Global Error Handling
// Create a global error handler
const globalErrorHandlers = new Set<(error: string) => void>();
export const addGlobalErrorHandler = (handler: (error: string) => void) => {
globalErrorHandlers.add(handler);
return () => globalErrorHandlers.delete(handler);
};
const notifyGlobalErrorHandlers = (error: string) => {
globalErrorHandlers.forEach((handler) => handler(error));
};
// Use in your hook
function useApi<T = any>(url: string | null, options: UseApiOptions = {}) {
// ... existing code ...
const fetchData = async (...args) => {
try {
// ... fetch logic ...
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'An error occurred';
// Notify global handlers
notifyGlobalErrorHandlers(errorMessage);
if (err instanceof Error && err.name !== 'AbortError') {
setState((prev) => ({ ...prev, error: errorMessage, loading: false }));
onError?.(errorMessage);
throw err;
}
}
};
// ... rest of hook ...
}
3. Request Interceptors
interface RequestInterceptor {
request?: (config: RequestInit) => RequestInit | Promise<RequestInit>;
response?: (response: Response) => Response | Promise<Response>;
error?: (error: Error) => Error | Promise<Error>;
}
class ApiClient {
private interceptors: RequestInterceptor[] = [];
addInterceptor(interceptor: RequestInterceptor) {
this.interceptors.push(interceptor);
}
async fetch(url: string, config: RequestInit = {}): Promise<Response> {
// Apply request interceptors
let finalConfig = config;
for (const interceptor of this.interceptors) {
if (interceptor.request) {
finalConfig = await interceptor.request(finalConfig);
}
}
try {
let response = await fetch(url, finalConfig);
// Apply response interceptors
for (const interceptor of this.interceptors) {
if (interceptor.response) {
response = await interceptor.response(response);
}
}
return response;
} catch (error) {
// Apply error interceptors
let finalError = error as Error;
for (const interceptor of this.interceptors) {
if (interceptor.error) {
finalError = await interceptor.error(finalError);
}
}
throw finalError;
}
}
}
// Usage in your hook
const apiClient = new ApiClient();
// Add auth interceptor
apiClient.addInterceptor({
request: (config) => ({
...config,
headers: {
...config.headers,
Authorization: `Bearer ${getAuthToken()}`,
},
}),
});
// Add retry interceptor for 401s
apiClient.addInterceptor({
error: async (error) => {
if (error.message.includes('401')) {
await refreshToken();
throw new Error('RETRY_REQUEST');
}
throw error;
},
});
Conclusion
Building a custom React hook for API calls provides numerous benefits:
- Reusability: Write once, use everywhere
- Consistency: Standardized error handling and loading states
- Performance: Built-in caching and request cancellation
- Maintainability: Centralized API logic
- Type Safety: Full TypeScript support
- Testing: Easily testable in isolation
Key Features We Implemented:
- ✅ Loading states and error handling
- ✅ Request cancellation with AbortController
- ✅ Manual refetch capability
- ✅ TypeScript support with generics
- ✅ Caching mechanism with TTL
- ✅ Retry logic with exponential backoff
- ✅ Stale-while-revalidate pattern
- ✅ Memory management and cleanup
Next Steps:
- Add more HTTP methods (POST, PUT, DELETE)
- Implement optimistic updates
- Add pagination support
- Create mutation hooks
- Add GraphQL support
- Implement offline capabilities
The hook we've built is production-ready and can handle most API call scenarios in modern React applications. Remember to adapt it based on your specific needs and requirements.
Happy coding! 🚀
Want to see more React tutorials and advanced patterns? Follow me for more in-depth guides on modern React development.