173 lines
5.2 KiB
TypeScript
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 };
|
|
}
|
|
|