WellNuo/hooks/useApiWithErrorHandling.ts
Sergei 3260119ece Add comprehensive network error handling system
- 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>
2026-02-01 09:29:19 -08:00

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;