import { useState, useCallback, useEffect, useRef } from 'react'; import { toast } from 'react-toastify'; import { logger } from '../utils/logger'; interface UseApiCallOptions { showSuccessToast?: boolean; successMessage?: string; showErrorToast?: boolean; onSuccess?: (data: any) => void; onError?: (error: any) => void; cancelOnUnmount?: boolean; // Cancel request on component unmount } interface UseApiCallReturn { execute: (...args: any[]) => Promise; isLoading: boolean; error: Error | null; clearError: () => void; cancel: () => void; // Manual cancellation function } /** * Custom hook for handling API calls with loading states and error handling * Includes request cancellation on component unmount to prevent memory leaks * * @param apiFunction - The async API function to call * @param options - Configuration options * @returns Object with execute function, loading state, error state, clearError function, and cancel function */ export function useApiCall( apiFunction: (...args: any[]) => Promise, options: UseApiCallOptions = {} ): UseApiCallReturn { const { showSuccessToast = false, successMessage = 'Operation completed successfully', showErrorToast = true, onSuccess, onError, cancelOnUnmount = true, // Default to true for safety } = options; const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const abortControllerRef = useRef(null); const mountedRef = useRef(true); // Track component mount status useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; // Cancel pending request on unmount if enabled if (cancelOnUnmount && abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; } }; }, [cancelOnUnmount]); const cancel = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; setIsLoading(false); } }, []); const execute = useCallback( async (...args: any[]): Promise => { // Cancel any previous request if (abortControllerRef.current) { abortControllerRef.current.abort(); } // Create new AbortController for this request const abortController = new AbortController(); abortControllerRef.current = abortController; try { setIsLoading(true); setError(null); // Check if component is still mounted before making request if (!mountedRef.current) { return undefined; } // Check if apiFunction supports AbortSignal // If the function accepts a signal parameter, pass it let result: T; if (args.length > 0 && typeof args[args.length - 1] === 'object' && 'signal' in args[args.length - 1]) { // Function already has signal in arguments result = await apiFunction(...args); } else { // Try to pass signal as last argument if function supports it try { result = await apiFunction(...args, { signal: abortController.signal }); } catch (err: any) { // If function doesn't support signal, try without it if (err.name === 'TypeError' && err.message.includes('signal')) { result = await apiFunction(...args); } else { throw err; } } } // Check if component is still mounted before updating state if (!mountedRef.current) { return undefined; } // Check if request was cancelled if (abortController.signal.aborted) { return undefined; } if (showSuccessToast) { toast.success(successMessage); } if (onSuccess) { onSuccess(result); } abortControllerRef.current = null; return result; } catch (err: any) { // Don't handle errors if component is unmounted or request was cancelled if (!mountedRef.current || abortController.signal.aborted || err.name === 'AbortError') { return undefined; } const errorMessage = err?.response?.data?.message || err?.response?.data?.detail || err?.message || 'An error occurred. Please try again.'; const apiError = err instanceof Error ? err : new Error(errorMessage); setError(apiError); logger.error('API call failed', err); if (showErrorToast && !err.isCancelled) { toast.error(errorMessage); } if (onError && !err.isCancelled) { onError(err); } throw err; } finally { // Only update loading state if component is still mounted if (mountedRef.current) { setIsLoading(false); } abortControllerRef.current = null; } }, [apiFunction, showSuccessToast, successMessage, showErrorToast, onSuccess, onError] ); const clearError = useCallback(() => { setError(null); }, []); return { execute, isLoading, error, clearError, cancel }; }