374 lines
9.2 KiB
TypeScript
374 lines
9.2 KiB
TypeScript
'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;
|
|
} |