Creating custom hooks for API calls with caching capabilities is a powerful way to manage server state in React applications. Here’s a comprehensive guide to building robust API hooks with caching, error handling, and more.
Basic API Hook
1. Simple Fetch Hook
import { useState, useEffect } from 'react';
export function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
// Usage
const { data, loading, error } = useFetch('/api/users');
Advanced API Hook with Caching
2. Hook with Local Storage Caching
import { useState, useEffect } from 'react';
export function useFetchWithCache(url, cacheKey) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
// Check cache first
const cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
setData(JSON.parse(cachedData));
setLoading(false);
}
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
setData(json);
localStorage.setItem(cacheKey, JSON.stringify(json));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url, cacheKey]);
return { data, loading, error };
}
// Usage
const { data, loading, error } = useFetchWithCache(
'/api/users',
'users-cache'
);
3. Hook with In-Memory Cache and Refetch
import { useState, useEffect } from 'react';
const cache = new Map();
export function useApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = async (forceRefresh = false) => {
try {
setLoading(true);
setError(null);
if (!forceRefresh && cache.has(url)) {
setData(cache.get(url));
setLoading(false);
return;
}
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
cache.set(url, json);
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: () => fetchData(true) };
}
// Usage
const { data, loading, error, refetch } = useApi('/api/users');
Hook with Advanced Features
4. API Hook with Abort Controller
import { useState, useEffect } from 'react';
export function useAbortableFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
const fetchData = async () => {
try {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
if (!signal.aborted) {
setLoading(false);
}
}
};
fetchData();
return () => {
controller.abort();
};
}, [url]);
return { data, loading, error };
}
5. Hook with Request Deduplication
import { useState, useEffect } from 'react';
const pendingRequests = new Map();
export function useDedupeFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isMounted = true;
const fetchData = async () => {
// Check if request is already pending
if (pendingRequests.has(url)) {
const existingRequest = pendingRequests.get(url);
try {
const result = await existingRequest;
if (isMounted) setData(result);
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setLoading(false);
}
return;
}
// Create new request
const request = fetch(url)
.then(response => {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
})
.finally(() => {
pendingRequests.delete(url);
});
pendingRequests.set(url, request);
try {
const result = await request;
if (isMounted) setData(result);
} catch (err) {
if (isMounted) setError(err.message);
} finally {
if (isMounted) setLoading(false);
}
};
fetchData();
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
TypeScript Implementation
6. Typed API Hook with Cache
import { useState, useEffect } from 'react';
interface ApiResponse<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useApi<T>(url: string): ApiResponse<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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 json: T = await response.json();
setData(json);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, loading, error, refetch: fetchData };
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
const { data: users, loading, error } = useApi<User[]>('/api/users');
Hook with Optimistic Updates
7. API Hook with Optimistic UI
import { useState } from 'react';
export function useOptimisticApi(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const mutate = async (newData, optimisticUpdate) => {
try {
// Apply optimistic update
if (optimisticUpdate) {
setData(prev => optimisticUpdate(prev));
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newData)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
// Revert optimistic update on error
if (optimisticUpdate) {
setData(prev => {
// Implement your rollback logic here
return prev; // Simplified example
});
}
}
};
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error, mutate };
}
// Usage
const { data, mutate } = useOptimisticApi('/api/todos');
const handleAddTodo = (newTodo) => {
mutate(newTodo, (prevTodos) => [...prevTodos, { ...newTodo, id: Date.now() }]);
};
Best Practices
- Error Handling: Always handle errors gracefully and provide useful error messages
- Cleanup: Implement cleanup in useEffect to avoid memory leaks
- Dependency Arrays: Include all dependencies to avoid stale closures
- Type Safety: Use TypeScript for better developer experience
- Testing: Write tests for your hooks to ensure reliability
- Performance: Implement caching and request deduplication where appropriate
- Abort Controllers: Cancel pending requests when components unmount
Testing Custom Hooks
import { renderHook, act } from '@testing-library/react-hooks';
import { useApi } from './useApi';
describe('useApi', () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: 'test' }),
})
);
});
it('should fetch data successfully', async () => {
const { result, waitForNextUpdate } = renderHook(() => useApi('/test'));
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual({ data: 'test' });
expect(result.current.error).toBeNull();
});
it('should handle errors', async () => {
global.fetch.mockImplementationOnce(() =>
Promise.reject(new Error('Network error'))
);
const { result, waitForNextUpdate } = renderHook(() => useApi('/test'));
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe('Network error');
});
});
These custom hooks provide a solid foundation for handling API calls in React applications. You can extend them with additional features like retry logic, offline support, or integration with state management libraries based on your application’s needs.