'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( options: UseDataFetchingOptions ): UseDataFetchingResult { 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(null); const [isLoading, setIsLoading] = useState(false); const [isFetching, setIsFetching] = useState(false); const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); const [isStale, setIsStale] = useState(true); // Refs for caching and retry logic const cacheRef = useRef<{ data: T | null; timestamp: number; isStale: boolean; } | null>(null); const retryCountRef = useRef(0); const isMountedRef = useRef(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 => { 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; } /** * useDataFetchingWithPagination Hook * * Extension of useDataFetching for paginated data */ export function useDataFetchingWithPagination( options: UseDataFetchingOptions & { initialPage?: number; pageSize?: number; } ) { const { initialPage = 1, pageSize = 10, queryFn, ...restOptions } = options; const [page, setPage] = useState(initialPage); const [hasMore, setHasMore] = useState(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({ ...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( options: UseDataFetchingOptions & { refreshInterval?: number; // milliseconds } ) { const { refreshInterval, ...restOptions } = options; const result = useDataFetching(restOptions); useEffect(() => { if (!refreshInterval) return; const interval = setInterval(() => { if (!result.isLoading && !result.isFetching) { result.refetch(); } }, refreshInterval); return () => clearInterval(interval); }, [refreshInterval, result]); return result; }