- 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>
467 lines
12 KiB
TypeScript
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;
|