Files
Hotel-Booking/Frontend/src/hooks/useApiCall.ts
Iliyan Angelov 312f85530c updates
2025-11-28 02:40:05 +02:00

173 lines
5.2 KiB
TypeScript

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<T> {
execute: (...args: any[]) => Promise<T | undefined>;
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<T = any>(
apiFunction: (...args: any[]) => Promise<T>,
options: UseApiCallOptions = {}
): UseApiCallReturn<T> {
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<Error | null>(null);
const abortControllerRef = useRef<AbortController | null>(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<T | undefined> => {
// 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 };
}