Data fetching is a fundamental part of modern web applications, but managing loading states, errors, retries, and cleanup can be complex. In this post, we'll dive deep into useFetcher
, a powerful custom React hook that simplifies data fetching while providing advanced features like automatic retries, abort handling, and cleanup.
The Problem
When fetching data in React components, we often need to handle several concerns:
- Loading states
- Error handling
- Automatic retries
- Cleanup on unmount
- Aborting ongoing requests
- Periodic refetching
- Memory leaks
Managing all these aspects manually can lead to verbose, error-prone code. Let's see how we can solve this with a custom hook.
Introducing useFetcher
useFetcher
is a custom hook that encapsulates all these concerns into a clean, reusable interface. Here's a basic example of how to use it:
const { data, loading, error, refetch } = useFetcher(
async () => {
const response = await fetch('/api/users');
const data = await response.json();
return { data };
},
{ fetchOnLoad: true }
);
Key Features
1. Type Safety
The hook is fully typed using TypeScript generics:
type FetcherResponse<T> = {
data: T;
};
interface FetcherOptions<T> {
fetchOnLoad?: boolean;
retryIntervalInSec?: number;
initialData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
enabled?: boolean;
}
This ensures type safety throughout your application and provides excellent IDE support.
2. Automatic Loading States
The hook manages loading states internally, providing a boolean loading
flag that you can use to show loading indicators:
const { loading } = useFetcher(fetchData);
return loading ? <LoadingSpinner /> : <DataDisplay />;
3. Error Handling
Errors are caught and normalized into a consistent format:
const { error } = useFetcher(fetchData, {
onError: (error) => {
console.error('Fetch failed:', error);
}
});
4. Automatic Retries
You can enable automatic retrying with a specified interval:
const { data } = useFetcher(fetchData, {
retryIntervalInSec: 30 // Retry every 30 seconds
});
5. Abort Control
The hook automatically handles request abortion when the component unmounts or when a new request is initiated:
const { refetch } = useFetcher(fetchData);
// Calling refetch() will abort any ongoing request
// and start a new one
Advanced Usage
Conditional Fetching
You can control when the fetcher is enabled:
const { data } = useFetcher(fetchUserData, {
enabled: !!userId, // Only fetch when userId is available
fetchOnLoad: true
});
Success and Error Callbacks
Handle success and error cases with callbacks:
const { data } = useFetcher(fetchData, {
onSuccess: (data) => {
toast.success('Data loaded successfully!');
},
onError: (error) => {
toast.error(`Failed to load: ${error.message}`);
}
});
Complete Implementation
Here's the full implementation of the useFetcher
hook:
import { useCallback, useEffect, useRef, useState } from 'react';
type FetcherResponse<T> = {
data: T;
};
interface FetcherOptions<T> {
fetchOnLoad?: boolean;
retryIntervalInSec?: number;
initialData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
enabled?: boolean;
}
interface FetcherState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
export default function useFetcher<T>(
callback: (signal?: AbortSignal) => Promise<FetcherResponse<T>>,
options: FetcherOptions<T> = {}
) {
const {
fetchOnLoad = false,
retryIntervalInSec,
onSuccess,
onError,
enabled = true,
} = options;
const intervalRef = useRef<ReturnType<typeof setInterval>>();
const abortControllerRef = useRef<AbortController>();
const isMountedRef = useRef(true);
const [state, setState] = useState<FetcherState<T>>({
data: null,
error: null,
loading: false,
});
const fetcher = useCallback(
async (signal?: AbortSignal) => {
if (!enabled) return;
setState((prev) => ({ ...prev, loading: true }));
try {
const response = await callback(signal);
if (!isMountedRef.current) return;
setState({
data: response.data,
error: null,
loading: false,
});
onSuccess?.(response.data);
} catch (error: any) {
if (error.name === 'AbortError') return;
if (!isMountedRef.current) return;
const errorObject = error instanceof Error ? error : new Error(
error?.message || 'Unknown error'
);
if ('status' in error) {
(errorObject as any).status = error.status;
}
setState((prev) => ({
...prev,
error: errorObject,
loading: false,
}));
onError?.(errorObject);
}
},
[callback, enabled, onSuccess, onError]
);
useEffect(() => {
if (
fetchOnLoad &&
enabled &&
!state.data &&
!state.error &&
!state.loading
) {
fetcher();
}
}, [fetchOnLoad, enabled, state.data, state.error, state.loading, fetcher]);
useEffect(() => {
if (enabled && retryIntervalInSec && !state.error) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
intervalRef.current = setInterval(fetcher, retryIntervalInSec * 1000);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [enabled, retryIntervalInSec, state.error, fetcher]);
useEffect(() => {
return () => {
isMountedRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
const refetch = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
return fetcher(abortControllerRef.current.signal);
}, [fetcher]);
return {
...state,
refetch,
};
}
Usage Examples
Let's look at some real-world examples of how to use this hook:
Basic Usage
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetcher<User>(
async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return { data };
},
{ fetchOnLoad: true }
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return <div>Hello, {data.name}!</div>;
}
With Auto-Retry
function LiveDataFeed() {
const { data } = useFetcher<LiveData>(
async () => {
const response = await fetch('/api/live-feed');
const data = await response.json();
return { data };
},
{
fetchOnLoad: true,
retryIntervalInSec: 30,
onError: (error) => {
console.error('Failed to fetch live data:', error);
}
}
);
return <div>{data?.latestValue}</div>;
}
With Abort Control
function SearchResults({ query }: { query: string }) {
const { data, refetch } = useFetcher<SearchResult[]>(
async (signal) => {
const response = await fetch(`/api/search?q=${query}`, { signal });
const data = await response.json();
return { data };
},
{ enabled: query.length > 0 }
);
useEffect(() => {
refetch();
}, [query, refetch]);
return <ResultsList results={data || []} />;
}
Best Practices
-
Always provide error handling: Use the
onError
callback or handle the error state in your UI. - Be mindful of retry intervals: Choose appropriate retry intervals based on your use case.
- Clean up resources: The hook handles cleanup automatically, but be aware of any additional cleanup needed in your fetch callback.
- Use TypeScript: Take advantage of the type safety provided by the hook.
Conclusion
The useFetcher
hook provides a robust solution for handling data fetching in React applications. With its TypeScript support, automatic cleanup, and advanced features like retries and abort control, it can significantly simplify your data fetching logic while maintaining good practices and avoiding common pitfalls.
Feel free to use this hook in your projects and adapt it to your specific needs. The complete implementation is available above, and you can modify it to add more features or adjust the behavior to match your requirements.
Happy Coding 😊!
Top comments (0)