/** * useAsync - Enhanced async state management hook * * Features: * - Loading, success, error states * - Automatic error handling * - Retry with exponential backoff * - Optimistic updates * - Abort/cancel support * - Stale data indication */ import { useState, useCallback, useRef, useEffect } from 'react'; import { AppError } from '@/types/errors'; import { ApiResponse } from '@/types'; import { createErrorFromApi, createErrorFromUnknown, calculateRetryDelay, logError, } from '@/services/errorHandler'; // Async operation states export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error'; // Async state shape export interface AsyncState { status: AsyncStatus; data: T | null; error: AppError | null; isLoading: boolean; isSuccess: boolean; isError: boolean; isIdle: boolean; isStale: boolean; lastUpdated: Date | null; } // Options for useAsync interface UseAsyncOptions { // Initial data initialData?: T | null; // Run immediately on mount immediate?: boolean; // Keep previous data while loading keepPreviousData?: boolean; // Stale time in ms (after which data is considered stale) staleTime?: number; // Auto-retry on error autoRetry?: boolean; // Max retry attempts maxRetries?: number; // Callback on success onSuccess?: (data: T) => void; // Callback on error onError?: (error: AppError) => void; // Callback on settle (success or error) onSettled?: (data: T | null, error: AppError | null) => void; } // Return type interface UseAsyncReturn { // State state: AsyncState; data: T | null; error: AppError | null; status: AsyncStatus; isLoading: boolean; isSuccess: boolean; isError: boolean; isIdle: boolean; isStale: boolean; // Actions execute: (...args: Args) => Promise; reset: () => void; setData: (data: T | ((prev: T | null) => T)) => void; setError: (error: AppError | null) => void; retry: () => Promise; cancel: () => void; } // Create initial state function createInitialState(initialData?: T | null): AsyncState { return { status: initialData !== undefined && initialData !== null ? 'success' : 'idle', data: initialData ?? null, error: null, isLoading: false, isSuccess: initialData !== undefined && initialData !== null, isError: false, isIdle: initialData === undefined || initialData === null, isStale: false, lastUpdated: initialData !== undefined && initialData !== null ? new Date() : null, }; } export function useAsync( asyncFn: (...args: Args) => Promise>, options: UseAsyncOptions = {} ): UseAsyncReturn { const { initialData, immediate = false, keepPreviousData = true, staleTime, autoRetry = false, maxRetries = 3, onSuccess, onError, onSettled, } = options; const [state, setState] = useState>(() => createInitialState(initialData) ); // Refs const mountedRef = useRef(true); const abortControllerRef = useRef(null); const lastArgsRef = useRef(null); const retryCountRef = useRef(0); // Check if data is stale useEffect(() => { if (!staleTime || !state.lastUpdated || state.isLoading) { return; } const checkStale = () => { const now = new Date(); const elapsed = now.getTime() - state.lastUpdated!.getTime(); if (elapsed > staleTime && !state.isStale) { setState((prev) => ({ ...prev, isStale: true })); } }; // Check immediately checkStale(); // Set up interval const interval = setInterval(checkStale, Math.min(staleTime / 2, 30000)); return () => clearInterval(interval); }, [staleTime, state.lastUpdated, state.isLoading, state.isStale]); // Cleanup on unmount useEffect(() => { return () => { mountedRef.current = false; abortControllerRef.current?.abort(); }; }, []); // Execute the async function const execute = useCallback( async (...args: Args): Promise => { // Cancel previous request abortControllerRef.current?.abort(); abortControllerRef.current = new AbortController(); // Store args for retry lastArgsRef.current = args; retryCountRef.current = 0; // Set loading state setState((prev) => ({ ...prev, status: 'loading', isLoading: true, isIdle: false, error: keepPreviousData ? null : prev.error, data: keepPreviousData ? prev.data : null, })); const executeWithRetry = async (attempt: number): Promise => { try { const response = await asyncFn(...args); // Check if aborted or unmounted if (!mountedRef.current || abortControllerRef.current?.signal.aborted) { return null; } if (response.ok && response.data !== undefined) { const data = response.data; setState({ status: 'success', data, error: null, isLoading: false, isSuccess: true, isError: false, isIdle: false, isStale: false, lastUpdated: new Date(), }); onSuccess?.(data); onSettled?.(data, null); return data; } // Handle error if (response.error) { const appError = createErrorFromApi(response.error); // Check if should retry if ( autoRetry && appError.retry.isRetryable && attempt < maxRetries ) { const delay = calculateRetryDelay(appError.retry, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); if (!mountedRef.current || abortControllerRef.current?.signal.aborted) { return null; } return executeWithRetry(attempt + 1); } // Log and set error logError(appError); setState({ status: 'error', data: keepPreviousData ? state.data : null, error: appError, isLoading: false, isSuccess: false, isError: true, isIdle: false, isStale: false, lastUpdated: state.lastUpdated, }); onError?.(appError); onSettled?.(null, appError); } return null; } catch (error) { // Check if aborted if (!mountedRef.current || abortControllerRef.current?.signal.aborted) { return null; } const appError = createErrorFromUnknown(error); // Check if should retry if ( autoRetry && appError.retry.isRetryable && attempt < maxRetries ) { const delay = calculateRetryDelay(appError.retry, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); if (!mountedRef.current || abortControllerRef.current?.signal.aborted) { return null; } return executeWithRetry(attempt + 1); } // Log and set error logError(appError); setState({ status: 'error', data: keepPreviousData ? state.data : null, error: appError, isLoading: false, isSuccess: false, isError: true, isIdle: false, isStale: false, lastUpdated: state.lastUpdated, }); onError?.(appError); onSettled?.(null, appError); return null; } }; return executeWithRetry(0); }, [ asyncFn, keepPreviousData, autoRetry, maxRetries, onSuccess, onError, onSettled, state.data, state.lastUpdated, ] ); // Reset to initial state const reset = useCallback(() => { abortControllerRef.current?.abort(); setState(createInitialState(initialData)); }, [initialData]); // Set data manually const setData = useCallback((data: T | ((prev: T | null) => T)) => { setState((prev) => { const newData = typeof data === 'function' ? (data as (prev: T | null) => T)(prev.data) : data; return { ...prev, status: 'success', data: newData, error: null, isLoading: false, isSuccess: true, isError: false, isIdle: false, isStale: false, lastUpdated: new Date(), }; }); }, []); // Set error manually const setError = useCallback((error: AppError | null) => { setState((prev) => ({ ...prev, status: error ? 'error' : 'idle', error, isLoading: false, isSuccess: false, isError: !!error, isIdle: !error, })); }, []); // Retry last execution const retry = useCallback(async (): Promise => { if (!lastArgsRef.current) { return null; } return execute(...lastArgsRef.current); }, [execute]); // Cancel current execution const cancel = useCallback(() => { abortControllerRef.current?.abort(); setState((prev) => ({ ...prev, isLoading: false, })); }, []); // Execute immediately if specified useEffect(() => { if (immediate) { execute(...([] as unknown as Args)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { state, data: state.data, error: state.error, status: state.status, isLoading: state.isLoading, isSuccess: state.isSuccess, isError: state.isError, isIdle: state.isIdle, isStale: state.isStale, execute, reset, setData, setError, retry, cancel, }; } /** * useAsyncCallback - Similar to useAsync but for one-off operations * (like form submissions) rather than data fetching */ export function useAsyncCallback( asyncFn: (...args: Args) => Promise, options: Omit, 'immediate'> = {} ) { const { onSuccess, onError, onSettled, autoRetry = false, maxRetries = 3 } = options; const [state, setState] = useState<{ isLoading: boolean; error: AppError | null; }>({ isLoading: false, error: null, }); const mountedRef = useRef(true); useEffect(() => { return () => { mountedRef.current = false; }; }, []); const execute = useCallback( async (...args: Args): Promise => { setState({ isLoading: true, error: null }); const executeWithRetry = async (attempt: number): Promise => { try { const result = await asyncFn(...args); if (!mountedRef.current) return null; setState({ isLoading: false, error: null }); onSuccess?.(result); onSettled?.(result, null); return result; } catch (error) { if (!mountedRef.current) return null; const appError = createErrorFromUnknown(error); if (autoRetry && appError.retry.isRetryable && attempt < maxRetries) { const delay = calculateRetryDelay(appError.retry, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); if (!mountedRef.current) return null; return executeWithRetry(attempt + 1); } logError(appError); setState({ isLoading: false, error: appError }); onError?.(appError); onSettled?.(null, appError); return null; } }; return executeWithRetry(0); }, [asyncFn, autoRetry, maxRetries, onSuccess, onError, onSettled] ); const reset = useCallback(() => { setState({ isLoading: false, error: null }); }, []); return { execute, reset, isLoading: state.isLoading, error: state.error, isError: state.error !== null, }; } export default useAsync;