error and load state
This commit is contained in:
374
apps/website/components/shared/hooks/useDataFetching.ts
Normal file
374
apps/website/components/shared/hooks/useDataFetching.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { UseDataFetchingOptions, UseDataFetchingResult } from '../types/state.types';
|
||||
import { delay, retryWithBackoff } from '@/lib/utils/errorUtils';
|
||||
|
||||
/**
|
||||
* useDataFetching Hook
|
||||
*
|
||||
* Unified data fetching hook with built-in state management, error handling,
|
||||
* retry logic, and caching support.
|
||||
*
|
||||
* Features:
|
||||
* - Automatic loading state management
|
||||
* - Error classification and handling
|
||||
* - Built-in retry with exponential backoff
|
||||
* - Cache and stale time support
|
||||
* - Refetch capability
|
||||
* - Success/error callbacks
|
||||
* - Auto-retry on mount for recoverable errors
|
||||
*
|
||||
* Usage Example:
|
||||
* ```typescript
|
||||
* const { data, isLoading, error, retry, refetch } = useDataFetching({
|
||||
* queryKey: ['dashboardOverview'],
|
||||
* queryFn: () => dashboardService.getDashboardOverview(),
|
||||
* retryOnMount: true,
|
||||
* cacheTime: 5 * 60 * 1000,
|
||||
* onSuccess: (data) => console.log('Loaded:', data),
|
||||
* onError: (error) => console.error('Error:', error),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useDataFetching<T>(
|
||||
options: UseDataFetchingOptions<T>
|
||||
): UseDataFetchingResult<T> {
|
||||
const {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled = true,
|
||||
retryOnMount = false,
|
||||
cacheTime = 5 * 60 * 1000, // 5 minutes
|
||||
staleTime = 1 * 60 * 1000, // 1 minute
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
onSuccess,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
// State management
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isFetching, setIsFetching] = useState<boolean>(false);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [isStale, setIsStale] = useState<boolean>(true);
|
||||
|
||||
// Refs for caching and retry logic
|
||||
const cacheRef = useRef<{
|
||||
data: T | null;
|
||||
timestamp: number;
|
||||
isStale: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const retryCountRef = useRef<number>(0);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
// Check if cache is valid
|
||||
const isCacheValid = useCallback((): boolean => {
|
||||
if (!cacheRef.current) return false;
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - cacheRef.current.timestamp;
|
||||
|
||||
// Cache is valid if within cacheTime and not stale
|
||||
return age < cacheTime && !cacheRef.current.isStale;
|
||||
}, [cacheTime]);
|
||||
|
||||
// Update cache
|
||||
const updateCache = useCallback((newData: T | null, isStale: boolean = false) => {
|
||||
cacheRef.current = {
|
||||
data: newData,
|
||||
timestamp: Date.now(),
|
||||
isStale,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Main fetch function
|
||||
const fetch = useCallback(async (isRetry: boolean = false): Promise<T | null> => {
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
if (!isRetry && isCacheValid() && cacheRef.current && cacheRef.current.data !== null) {
|
||||
setData(cacheRef.current.data);
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
setError(null);
|
||||
setLastUpdated(new Date(cacheRef.current.timestamp));
|
||||
setIsStale(false);
|
||||
return cacheRef.current.data;
|
||||
}
|
||||
|
||||
setIsFetching(true);
|
||||
if (!isRetry) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Execute the fetch with retry logic
|
||||
const result = await retryWithBackoff(
|
||||
async () => {
|
||||
retryCountRef.current++;
|
||||
return await queryFn();
|
||||
},
|
||||
maxRetries,
|
||||
retryDelay
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Success - update state and cache
|
||||
setData(result);
|
||||
setLastUpdated(new Date());
|
||||
setIsStale(false);
|
||||
updateCache(result, false);
|
||||
retryCountRef.current = 0; // Reset retry count on success
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess(result);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to ApiError if needed
|
||||
const apiError = err instanceof ApiError ? err : new ApiError(
|
||||
err instanceof Error ? err.message : 'An unexpected error occurred',
|
||||
'UNKNOWN_ERROR',
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
retryCount: retryCountRef.current,
|
||||
wasRetry: isRetry,
|
||||
},
|
||||
err instanceof Error ? err : undefined
|
||||
);
|
||||
|
||||
setError(apiError);
|
||||
|
||||
if (onError) {
|
||||
onError(apiError);
|
||||
}
|
||||
|
||||
// Mark cache as stale on error
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
setIsStale(true);
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
}
|
||||
}, [enabled, isCacheValid, queryFn, maxRetries, retryDelay, updateCache, onSuccess, onError]);
|
||||
|
||||
// Retry function
|
||||
const retry = useCallback(async () => {
|
||||
return await fetch(true);
|
||||
}, [fetch]);
|
||||
|
||||
// Refetch function
|
||||
const refetch = useCallback(async () => {
|
||||
// Force bypass cache
|
||||
cacheRef.current = null;
|
||||
return await fetch(false);
|
||||
}, [fetch]);
|
||||
|
||||
// Initial fetch and auto-retry on mount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
const initialize = async () => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Check if we should auto-retry on mount
|
||||
const shouldRetryOnMount = retryOnMount && error && error.isRetryable();
|
||||
|
||||
if (shouldRetryOnMount) {
|
||||
try {
|
||||
await retry();
|
||||
} catch (err) {
|
||||
// Error already set by retry
|
||||
}
|
||||
} else if (!data && !error) {
|
||||
// Initial fetch
|
||||
try {
|
||||
await fetch(false);
|
||||
} catch (err) {
|
||||
// Error already set by fetch
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initialize();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [enabled, retryOnMount]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Effect to check staleness
|
||||
useEffect(() => {
|
||||
if (!lastUpdated) return;
|
||||
|
||||
const checkStale = () => {
|
||||
if (!lastUpdated) return;
|
||||
|
||||
const now = Date.now();
|
||||
const age = now - lastUpdated.getTime();
|
||||
|
||||
if (age > staleTime) {
|
||||
setIsStale(true);
|
||||
if (cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(checkStale, 30000); // Check every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [lastUpdated, staleTime]);
|
||||
|
||||
// Effect to update cache staleness
|
||||
useEffect(() => {
|
||||
if (isStale && cacheRef.current) {
|
||||
cacheRef.current.isStale = true;
|
||||
}
|
||||
}, [isStale]);
|
||||
|
||||
// Clear cache function (useful for manual cache invalidation)
|
||||
const clearCache = useCallback(() => {
|
||||
cacheRef.current = null;
|
||||
setIsStale(true);
|
||||
}, []);
|
||||
|
||||
// Reset function (clears everything)
|
||||
const reset = useCallback(() => {
|
||||
setData(null);
|
||||
setIsLoading(false);
|
||||
setIsFetching(false);
|
||||
setError(null);
|
||||
setLastUpdated(null);
|
||||
setIsStale(true);
|
||||
cacheRef.current = null;
|
||||
retryCountRef.current = 0;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
retry,
|
||||
refetch,
|
||||
lastUpdated,
|
||||
isStale,
|
||||
// Additional utility functions (not part of standard interface but useful)
|
||||
_clearCache: clearCache,
|
||||
_reset: reset,
|
||||
} as UseDataFetchingResult<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDataFetchingWithPagination Hook
|
||||
*
|
||||
* Extension of useDataFetching for paginated data
|
||||
*/
|
||||
export function useDataFetchingWithPagination<T>(
|
||||
options: UseDataFetchingOptions<T[]> & {
|
||||
initialPage?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
) {
|
||||
const {
|
||||
initialPage = 1,
|
||||
pageSize = 10,
|
||||
queryFn,
|
||||
...restOptions
|
||||
} = options;
|
||||
|
||||
const [page, setPage] = useState<number>(initialPage);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
|
||||
const paginatedQueryFn = useCallback(async () => {
|
||||
const result = await queryFn();
|
||||
|
||||
// Check if there's more data
|
||||
if (Array.isArray(result)) {
|
||||
setHasMore(result.length === pageSize);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [queryFn, pageSize]);
|
||||
|
||||
const result = useDataFetching<T[]>({
|
||||
...restOptions,
|
||||
queryFn: paginatedQueryFn,
|
||||
});
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!hasMore) return;
|
||||
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
|
||||
// This would need to be integrated with the actual API
|
||||
// For now, we'll just refetch which may not be ideal
|
||||
await result.refetch();
|
||||
}, [page, hasMore, result]);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPage(initialPage);
|
||||
setHasMore(true);
|
||||
if (result._reset) {
|
||||
result._reset();
|
||||
}
|
||||
}, [initialPage, result]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
page,
|
||||
hasMore,
|
||||
loadMore,
|
||||
resetPagination,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* useDataFetchingWithRefresh Hook
|
||||
*
|
||||
* Extension with automatic refresh capability
|
||||
*/
|
||||
export function useDataFetchingWithRefresh<T>(
|
||||
options: UseDataFetchingOptions<T> & {
|
||||
refreshInterval?: number; // milliseconds
|
||||
}
|
||||
) {
|
||||
const { refreshInterval, ...restOptions } = options;
|
||||
const result = useDataFetching<T>(restOptions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshInterval) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!result.isLoading && !result.isFetching) {
|
||||
result.refetch();
|
||||
}
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshInterval, result]);
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user