- Add networkErrorRecovery utility with: - Request timeout handling via AbortController - Circuit breaker pattern to prevent cascading failures - Request deduplication for concurrent identical requests - Enhanced fetch with timeout, circuit breaker, and retry support - Add useApiWithErrorHandling hooks: - useApiCall for single API calls with auto error display - useMutation for mutations with optimistic update support - useMultipleApiCalls for parallel API execution - Add ErrorBoundary component: - Catches React errors in component tree - Displays fallback UI with retry option - Supports custom fallback components - withErrorBoundary HOC for easy wrapping - Add comprehensive tests (64 passing tests): - Circuit breaker state transitions - Request deduplication - Timeout detection - Error type classification - Hook behavior and error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
372 lines
10 KiB
TypeScript
372 lines
10 KiB
TypeScript
/**
|
|
* useApiWithErrorHandling - Automatic API Error Integration
|
|
*
|
|
* Provides a wrapper hook that automatically:
|
|
* - Shows errors in ErrorContext
|
|
* - Handles network errors gracefully
|
|
* - Provides retry functionality
|
|
* - Tracks loading states
|
|
*/
|
|
|
|
import { useCallback, useState, useRef } from 'react';
|
|
import { ApiResponse, ApiError } from '@/types';
|
|
import { AppError } from '@/types/errors';
|
|
import { createErrorFromApi, createNetworkError } from '@/services/errorHandler';
|
|
import { useShowErrorSafe } from '@/contexts/ErrorContext';
|
|
import { isNetworkError, isTimeoutError, toApiError } from '@/utils/networkErrorRecovery';
|
|
|
|
/**
|
|
* Options for API call execution
|
|
*/
|
|
interface ApiCallOptions {
|
|
/** Show error in ErrorContext (default: true) */
|
|
showError?: boolean;
|
|
/** Auto-dismiss error after timeout (default: based on severity) */
|
|
autoDismiss?: boolean;
|
|
/** Custom error message override */
|
|
errorMessage?: string;
|
|
/** Context data for error logging */
|
|
context?: Record<string, unknown>;
|
|
/** Callback on retry */
|
|
onRetry?: () => void;
|
|
/** Callback on error dismiss */
|
|
onDismiss?: () => void;
|
|
/** Callback on success */
|
|
onSuccess?: () => void;
|
|
/** Callback on error */
|
|
onError?: (error: AppError) => void;
|
|
}
|
|
|
|
/**
|
|
* Result from useApiCall hook
|
|
*/
|
|
interface UseApiCallResult<T> {
|
|
/** Execute the API call */
|
|
execute: () => Promise<ApiResponse<T>>;
|
|
/** Loading state */
|
|
isLoading: boolean;
|
|
/** Error from last call (if any) */
|
|
error: AppError | null;
|
|
/** Data from last successful call */
|
|
data: T | null;
|
|
/** Reset state */
|
|
reset: () => void;
|
|
/** Retry last call */
|
|
retry: () => Promise<ApiResponse<T>>;
|
|
}
|
|
|
|
/**
|
|
* Hook for executing API calls with automatic error handling
|
|
*
|
|
* @param apiCall - Function that returns ApiResponse
|
|
* @param options - Configuration options
|
|
* @returns Object with execute function, state, and utilities
|
|
*
|
|
* @example
|
|
* function MyComponent() {
|
|
* const { execute, isLoading, error, data } = useApiCall(
|
|
* () => api.getBeneficiary(id),
|
|
* { onSuccess: () => console.log('Loaded!') }
|
|
* );
|
|
*
|
|
* useEffect(() => {
|
|
* execute();
|
|
* }, []);
|
|
*
|
|
* if (isLoading) return <Loading />;
|
|
* if (error) return <ErrorMessage error={error} />;
|
|
* return <BeneficiaryView data={data} />;
|
|
* }
|
|
*/
|
|
export function useApiCall<T>(
|
|
apiCall: () => Promise<ApiResponse<T>>,
|
|
options: ApiCallOptions = {}
|
|
): UseApiCallResult<T> {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<AppError | null>(null);
|
|
const [data, setData] = useState<T | null>(null);
|
|
const showError = useShowErrorSafe();
|
|
const lastCallRef = useRef(apiCall);
|
|
|
|
// Update ref when apiCall changes
|
|
lastCallRef.current = apiCall;
|
|
|
|
const execute = useCallback(async (): Promise<ApiResponse<T>> => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await lastCallRef.current();
|
|
|
|
if (response.ok && response.data !== undefined) {
|
|
setData(response.data);
|
|
options.onSuccess?.();
|
|
return response;
|
|
}
|
|
|
|
// Handle API error
|
|
const apiError = response.error || { message: options.errorMessage || 'Request failed' };
|
|
const appError = createErrorFromApi(apiError, options.context);
|
|
|
|
setError(appError);
|
|
options.onError?.(appError);
|
|
|
|
// Show error in ErrorContext if enabled
|
|
if (options.showError !== false && showError) {
|
|
showError(appError, {
|
|
autoDismiss: options.autoDismiss,
|
|
onRetry: options.onRetry,
|
|
onDismiss: options.onDismiss,
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (err) {
|
|
// Handle exceptions (network errors, etc.)
|
|
let appError: AppError;
|
|
|
|
if (isTimeoutError(err)) {
|
|
appError = createNetworkError('Request timed out. Please try again.', true);
|
|
} else if (isNetworkError(err)) {
|
|
appError = createNetworkError('Network error. Please check your connection.');
|
|
} else {
|
|
const apiError = toApiError(err);
|
|
appError = createErrorFromApi(apiError, options.context);
|
|
}
|
|
|
|
setError(appError);
|
|
options.onError?.(appError);
|
|
|
|
// Show error in ErrorContext if enabled
|
|
if (options.showError !== false && showError) {
|
|
showError(appError, {
|
|
autoDismiss: options.autoDismiss ?? true,
|
|
onRetry: options.onRetry,
|
|
onDismiss: options.onDismiss,
|
|
});
|
|
}
|
|
|
|
return { ok: false, error: toApiError(err) };
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [options, showError]);
|
|
|
|
const reset = useCallback(() => {
|
|
setIsLoading(false);
|
|
setError(null);
|
|
setData(null);
|
|
}, []);
|
|
|
|
const retry = useCallback(() => {
|
|
return execute();
|
|
}, [execute]);
|
|
|
|
return {
|
|
execute,
|
|
isLoading,
|
|
error,
|
|
data,
|
|
reset,
|
|
retry,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for executing multiple API calls with unified error handling
|
|
*
|
|
* @example
|
|
* const { executeAll, isLoading, errors } = useMultipleApiCalls();
|
|
*
|
|
* const results = await executeAll([
|
|
* () => api.getBeneficiaries(),
|
|
* () => api.getProfile(),
|
|
* ]);
|
|
*/
|
|
export function useMultipleApiCalls() {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [errors, setErrors] = useState<AppError[]>([]);
|
|
const showError = useShowErrorSafe();
|
|
|
|
const executeAll = useCallback(
|
|
async <T extends unknown[]>(
|
|
calls: Array<() => Promise<ApiResponse<any>>>,
|
|
options: ApiCallOptions = {}
|
|
): Promise<Array<ApiResponse<any>>> => {
|
|
setIsLoading(true);
|
|
setErrors([]);
|
|
|
|
try {
|
|
const results = await Promise.all(calls.map(call => call()));
|
|
const newErrors: AppError[] = [];
|
|
|
|
results.forEach((result, index) => {
|
|
if (!result.ok && result.error) {
|
|
const appError = createErrorFromApi(result.error, {
|
|
...options.context,
|
|
callIndex: index,
|
|
});
|
|
newErrors.push(appError);
|
|
}
|
|
});
|
|
|
|
if (newErrors.length > 0) {
|
|
setErrors(newErrors);
|
|
|
|
// Show first error in ErrorContext
|
|
if (options.showError !== false && showError && newErrors[0]) {
|
|
showError(newErrors[0], {
|
|
autoDismiss: options.autoDismiss,
|
|
});
|
|
}
|
|
|
|
options.onError?.(newErrors[0]);
|
|
} else {
|
|
options.onSuccess?.();
|
|
}
|
|
|
|
return results;
|
|
} catch (err) {
|
|
const appError = createNetworkError(
|
|
err instanceof Error ? err.message : 'Multiple requests failed'
|
|
);
|
|
setErrors([appError]);
|
|
|
|
if (options.showError !== false && showError) {
|
|
showError(appError, { autoDismiss: true });
|
|
}
|
|
|
|
options.onError?.(appError);
|
|
return calls.map(() => ({ ok: false, error: toApiError(err) }));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[showError]
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
setIsLoading(false);
|
|
setErrors([]);
|
|
}, []);
|
|
|
|
return {
|
|
executeAll,
|
|
isLoading,
|
|
errors,
|
|
hasErrors: errors.length > 0,
|
|
reset,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook for mutation operations (create, update, delete)
|
|
* Provides optimistic updates and rollback on error
|
|
*
|
|
* @example
|
|
* const { mutate, isLoading } = useMutation(
|
|
* (name: string) => api.updateBeneficiary(id, { name }),
|
|
* {
|
|
* onSuccess: () => toast.success('Updated!'),
|
|
* onError: (error) => console.error(error),
|
|
* }
|
|
* );
|
|
*
|
|
* const handleSave = async () => {
|
|
* await mutate('New Name');
|
|
* };
|
|
*/
|
|
export function useMutation<TData, TVariables>(
|
|
mutationFn: (variables: TVariables) => Promise<ApiResponse<TData>>,
|
|
options: ApiCallOptions & {
|
|
/** Called before mutation starts */
|
|
onMutate?: (variables: TVariables) => void | Promise<void>;
|
|
/** Called when mutation succeeds */
|
|
onSuccess?: (data: TData, variables: TVariables) => void | Promise<void>;
|
|
/** Called when mutation fails */
|
|
onError?: (error: AppError, variables: TVariables) => void | Promise<void>;
|
|
/** Called after mutation completes (success or error) */
|
|
onSettled?: (data: TData | undefined, error: AppError | null, variables: TVariables) => void | Promise<void>;
|
|
} = {}
|
|
) {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<AppError | null>(null);
|
|
const [data, setData] = useState<TData | null>(null);
|
|
const showError = useShowErrorSafe();
|
|
|
|
const mutate = useCallback(
|
|
async (variables: TVariables): Promise<ApiResponse<TData>> => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Call onMutate for optimistic updates
|
|
await options.onMutate?.(variables);
|
|
|
|
const response = await mutationFn(variables);
|
|
|
|
if (response.ok && response.data !== undefined) {
|
|
setData(response.data);
|
|
await options.onSuccess?.(response.data, variables);
|
|
await options.onSettled?.(response.data, null, variables);
|
|
return response;
|
|
}
|
|
|
|
// Handle error
|
|
const apiError = response.error || { message: 'Mutation failed' };
|
|
const appError = createErrorFromApi(apiError, options.context);
|
|
|
|
setError(appError);
|
|
await options.onError?.(appError, variables);
|
|
await options.onSettled?.(undefined, appError, variables);
|
|
|
|
// Show error if enabled
|
|
if (options.showError !== false && showError) {
|
|
showError(appError, {
|
|
autoDismiss: options.autoDismiss,
|
|
onRetry: () => mutate(variables),
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (err) {
|
|
const appError = createNetworkError(
|
|
err instanceof Error ? err.message : 'Mutation failed'
|
|
);
|
|
|
|
setError(appError);
|
|
await options.onError?.(appError, variables);
|
|
await options.onSettled?.(undefined, appError, variables);
|
|
|
|
if (options.showError !== false && showError) {
|
|
showError(appError, { autoDismiss: true });
|
|
}
|
|
|
|
return { ok: false, error: toApiError(err) };
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[mutationFn, options, showError]
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
setIsLoading(false);
|
|
setError(null);
|
|
setData(null);
|
|
}, []);
|
|
|
|
return {
|
|
mutate,
|
|
isLoading,
|
|
error,
|
|
data,
|
|
reset,
|
|
isError: error !== null,
|
|
isSuccess: data !== null && error === null,
|
|
};
|
|
}
|
|
|
|
// Export all hooks
|
|
export default useApiCall;
|