WellNuo/hooks/useAsync.ts
Sergei a238b7e35f Add comprehensive error handling system
- Add extended error types with severity levels, retry policies,
  and contextual information (types/errors.ts)
- Create centralized error handler service with user-friendly
  message translation and classification (services/errorHandler.ts)
- Add ErrorContext for global error state management with
  auto-dismiss and error queue support (contexts/ErrorContext.tsx)
- Create error UI components: ErrorToast, FieldError,
  FieldErrorSummary, FullScreenError, EmptyState, OfflineState
- Add useError hook with retry strategies and API response handling
- Add useAsync hook for async operations with comprehensive state
- Create error message utilities with validation helpers
- Add tests for errorHandler and errorMessages (88 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:43:07 -08:00

467 lines
12 KiB
TypeScript

/**
* useAsync - Enhanced async state management hook
*
* Features:
* - Loading, success, error states
* - Automatic error handling
* - Retry with exponential backoff
* - Optimistic updates
* - Abort/cancel support
* - Stale data indication
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { AppError } from '@/types/errors';
import { ApiResponse } from '@/types';
import {
createErrorFromApi,
createErrorFromUnknown,
calculateRetryDelay,
logError,
} from '@/services/errorHandler';
// Async operation states
export type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
// Async state shape
export interface AsyncState<T> {
status: AsyncStatus;
data: T | null;
error: AppError | null;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
isIdle: boolean;
isStale: boolean;
lastUpdated: Date | null;
}
// Options for useAsync
interface UseAsyncOptions<T> {
// Initial data
initialData?: T | null;
// Run immediately on mount
immediate?: boolean;
// Keep previous data while loading
keepPreviousData?: boolean;
// Stale time in ms (after which data is considered stale)
staleTime?: number;
// Auto-retry on error
autoRetry?: boolean;
// Max retry attempts
maxRetries?: number;
// Callback on success
onSuccess?: (data: T) => void;
// Callback on error
onError?: (error: AppError) => void;
// Callback on settle (success or error)
onSettled?: (data: T | null, error: AppError | null) => void;
}
// Return type
interface UseAsyncReturn<T, Args extends unknown[]> {
// State
state: AsyncState<T>;
data: T | null;
error: AppError | null;
status: AsyncStatus;
isLoading: boolean;
isSuccess: boolean;
isError: boolean;
isIdle: boolean;
isStale: boolean;
// Actions
execute: (...args: Args) => Promise<T | null>;
reset: () => void;
setData: (data: T | ((prev: T | null) => T)) => void;
setError: (error: AppError | null) => void;
retry: () => Promise<T | null>;
cancel: () => void;
}
// Create initial state
function createInitialState<T>(initialData?: T | null): AsyncState<T> {
return {
status: initialData !== undefined && initialData !== null ? 'success' : 'idle',
data: initialData ?? null,
error: null,
isLoading: false,
isSuccess: initialData !== undefined && initialData !== null,
isError: false,
isIdle: initialData === undefined || initialData === null,
isStale: false,
lastUpdated: initialData !== undefined && initialData !== null ? new Date() : null,
};
}
export function useAsync<T, Args extends unknown[] = []>(
asyncFn: (...args: Args) => Promise<ApiResponse<T>>,
options: UseAsyncOptions<T> = {}
): UseAsyncReturn<T, Args> {
const {
initialData,
immediate = false,
keepPreviousData = true,
staleTime,
autoRetry = false,
maxRetries = 3,
onSuccess,
onError,
onSettled,
} = options;
const [state, setState] = useState<AsyncState<T>>(() =>
createInitialState(initialData)
);
// Refs
const mountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(null);
const lastArgsRef = useRef<Args | null>(null);
const retryCountRef = useRef(0);
// Check if data is stale
useEffect(() => {
if (!staleTime || !state.lastUpdated || state.isLoading) {
return;
}
const checkStale = () => {
const now = new Date();
const elapsed = now.getTime() - state.lastUpdated!.getTime();
if (elapsed > staleTime && !state.isStale) {
setState((prev) => ({ ...prev, isStale: true }));
}
};
// Check immediately
checkStale();
// Set up interval
const interval = setInterval(checkStale, Math.min(staleTime / 2, 30000));
return () => clearInterval(interval);
}, [staleTime, state.lastUpdated, state.isLoading, state.isStale]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
abortControllerRef.current?.abort();
};
}, []);
// Execute the async function
const execute = useCallback(
async (...args: Args): Promise<T | null> => {
// Cancel previous request
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
// Store args for retry
lastArgsRef.current = args;
retryCountRef.current = 0;
// Set loading state
setState((prev) => ({
...prev,
status: 'loading',
isLoading: true,
isIdle: false,
error: keepPreviousData ? null : prev.error,
data: keepPreviousData ? prev.data : null,
}));
const executeWithRetry = async (attempt: number): Promise<T | null> => {
try {
const response = await asyncFn(...args);
// Check if aborted or unmounted
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
if (response.ok && response.data !== undefined) {
const data = response.data;
setState({
status: 'success',
data,
error: null,
isLoading: false,
isSuccess: true,
isError: false,
isIdle: false,
isStale: false,
lastUpdated: new Date(),
});
onSuccess?.(data);
onSettled?.(data, null);
return data;
}
// Handle error
if (response.error) {
const appError = createErrorFromApi(response.error);
// Check if should retry
if (
autoRetry &&
appError.retry.isRetryable &&
attempt < maxRetries
) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
return executeWithRetry(attempt + 1);
}
// Log and set error
logError(appError);
setState({
status: 'error',
data: keepPreviousData ? state.data : null,
error: appError,
isLoading: false,
isSuccess: false,
isError: true,
isIdle: false,
isStale: false,
lastUpdated: state.lastUpdated,
});
onError?.(appError);
onSettled?.(null, appError);
}
return null;
} catch (error) {
// Check if aborted
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
const appError = createErrorFromUnknown(error);
// Check if should retry
if (
autoRetry &&
appError.retry.isRetryable &&
attempt < maxRetries
) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current || abortControllerRef.current?.signal.aborted) {
return null;
}
return executeWithRetry(attempt + 1);
}
// Log and set error
logError(appError);
setState({
status: 'error',
data: keepPreviousData ? state.data : null,
error: appError,
isLoading: false,
isSuccess: false,
isError: true,
isIdle: false,
isStale: false,
lastUpdated: state.lastUpdated,
});
onError?.(appError);
onSettled?.(null, appError);
return null;
}
};
return executeWithRetry(0);
},
[
asyncFn,
keepPreviousData,
autoRetry,
maxRetries,
onSuccess,
onError,
onSettled,
state.data,
state.lastUpdated,
]
);
// Reset to initial state
const reset = useCallback(() => {
abortControllerRef.current?.abort();
setState(createInitialState(initialData));
}, [initialData]);
// Set data manually
const setData = useCallback((data: T | ((prev: T | null) => T)) => {
setState((prev) => {
const newData = typeof data === 'function' ? (data as (prev: T | null) => T)(prev.data) : data;
return {
...prev,
status: 'success',
data: newData,
error: null,
isLoading: false,
isSuccess: true,
isError: false,
isIdle: false,
isStale: false,
lastUpdated: new Date(),
};
});
}, []);
// Set error manually
const setError = useCallback((error: AppError | null) => {
setState((prev) => ({
...prev,
status: error ? 'error' : 'idle',
error,
isLoading: false,
isSuccess: false,
isError: !!error,
isIdle: !error,
}));
}, []);
// Retry last execution
const retry = useCallback(async (): Promise<T | null> => {
if (!lastArgsRef.current) {
return null;
}
return execute(...lastArgsRef.current);
}, [execute]);
// Cancel current execution
const cancel = useCallback(() => {
abortControllerRef.current?.abort();
setState((prev) => ({
...prev,
isLoading: false,
}));
}, []);
// Execute immediately if specified
useEffect(() => {
if (immediate) {
execute(...([] as unknown as Args));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
state,
data: state.data,
error: state.error,
status: state.status,
isLoading: state.isLoading,
isSuccess: state.isSuccess,
isError: state.isError,
isIdle: state.isIdle,
isStale: state.isStale,
execute,
reset,
setData,
setError,
retry,
cancel,
};
}
/**
* useAsyncCallback - Similar to useAsync but for one-off operations
* (like form submissions) rather than data fetching
*/
export function useAsyncCallback<T, Args extends unknown[] = []>(
asyncFn: (...args: Args) => Promise<T>,
options: Omit<UseAsyncOptions<T>, 'immediate'> = {}
) {
const { onSuccess, onError, onSettled, autoRetry = false, maxRetries = 3 } = options;
const [state, setState] = useState<{
isLoading: boolean;
error: AppError | null;
}>({
isLoading: false,
error: null,
});
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const execute = useCallback(
async (...args: Args): Promise<T | null> => {
setState({ isLoading: true, error: null });
const executeWithRetry = async (attempt: number): Promise<T | null> => {
try {
const result = await asyncFn(...args);
if (!mountedRef.current) return null;
setState({ isLoading: false, error: null });
onSuccess?.(result);
onSettled?.(result, null);
return result;
} catch (error) {
if (!mountedRef.current) return null;
const appError = createErrorFromUnknown(error);
if (autoRetry && appError.retry.isRetryable && attempt < maxRetries) {
const delay = calculateRetryDelay(appError.retry, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
if (!mountedRef.current) return null;
return executeWithRetry(attempt + 1);
}
logError(appError);
setState({ isLoading: false, error: appError });
onError?.(appError);
onSettled?.(null, appError);
return null;
}
};
return executeWithRetry(0);
},
[asyncFn, autoRetry, maxRetries, onSuccess, onError, onSettled]
);
const reset = useCallback(() => {
setState({ isLoading: false, error: null });
}, []);
return {
execute,
reset,
isLoading: state.isLoading,
error: state.error,
isError: state.error !== null,
};
}
export default useAsync;