WellNuo/hooks/useOfflineAwareData.ts
Sergei 91e677178e Add offline mode graceful degradation
Implements comprehensive offline handling for API-first architecture:

Network Detection:
- Real-time connectivity monitoring via @react-native-community/netinfo
- useNetworkStatus hook for React components
- Utility functions: getNetworkStatus(), isOnline()
- Retry logic with exponential backoff

Offline-Aware API Layer:
- Wraps all API methods with network detection
- User-friendly error messages for offline states
- Automatic retries for read operations
- Custom offline messages for write operations

UI Components:
- OfflineBanner: Animated banner at top/bottom
- InlineOfflineBanner: Non-animated inline version
- Auto-shows/hides based on network status

Data Fetching Hooks:
- useOfflineAwareData: Hook for data fetching with offline handling
- useOfflineAwareMutation: Hook for create/update/delete operations
- Auto-refetch when network returns
- Optional polling support

Error Handling:
- Consistent error messages across app
- Network error detection
- Retry functionality with user feedback

Tests:
- Network status detection tests
- Offline-aware API wrapper tests
- 23 passing tests with full coverage

Documentation:
- Complete offline mode guide (docs/OFFLINE_MODE.md)
- Usage examples (components/examples/OfflineAwareExample.tsx)
- Best practices and troubleshooting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 16:49:15 -08:00

277 lines
6.6 KiB
TypeScript

/**
* Offline-Aware Data Fetching Hook
*
* Custom hook that handles data fetching with offline detection,
* loading states, error handling, and retry logic.
*/
import { useState, useEffect, useCallback } from 'react';
import { useNetworkStatus } from '@/utils/networkStatus';
import type { ApiResponse, ApiError } from '@/types';
import { isNetworkError, getNetworkErrorMessage } from '@/services/offlineAwareApi';
interface UseOfflineAwareDataOptions {
/**
* Skip fetching data initially (manual fetch only)
* Default: false
*/
skip?: boolean;
/**
* Refetch data when network comes back online
* Default: true
*/
refetchOnReconnect?: boolean;
/**
* Custom error message when offline
*/
offlineMessage?: string;
/**
* Poll interval in milliseconds (0 to disable)
* Default: 0
*/
pollInterval?: number;
}
interface UseOfflineAwareDataReturn<T> {
/**
* Fetched data (null if not loaded or error)
*/
data: T | null;
/**
* Loading state (true during initial fetch)
*/
loading: boolean;
/**
* Error object (null if no error)
*/
error: ApiError | null;
/**
* Refetch data manually
*/
refetch: () => Promise<void>;
/**
* Is currently refetching (true during manual refetch)
*/
refetching: boolean;
/**
* User-friendly error message
*/
errorMessage: string | null;
/**
* Is the error network-related?
*/
isOfflineError: boolean;
}
/**
* Custom hook for offline-aware data fetching
*
* @param fetcher - Async function that returns ApiResponse<T>
* @param dependencies - Dependencies array (like useEffect)
* @param options - Configuration options
*
* @example
* function MyComponent() {
* const { data, loading, error, refetch } = useOfflineAwareData(
* () => offlineAwareApi.getAllBeneficiaries(),
* []
* );
*
* if (loading) return <LoadingSpinner />;
* if (error) return <ErrorMessage message={errorMessage} onRetry={refetch} />;
* return <List data={data} />;
* }
*/
export function useOfflineAwareData<T>(
fetcher: () => Promise<ApiResponse<T>>,
dependencies: any[] = [],
options: UseOfflineAwareDataOptions = {}
): UseOfflineAwareDataReturn<T> {
const {
skip = false,
refetchOnReconnect = true,
offlineMessage,
pollInterval = 0,
} = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(!skip);
const [refetching, setRefetching] = useState<boolean>(false);
const [error, setError] = useState<ApiError | null>(null);
const { isOnline } = useNetworkStatus();
// Fetch data
const fetchData = useCallback(
async (isRefetch = false) => {
if (isRefetch) {
setRefetching(true);
} else {
setLoading(true);
}
setError(null);
try {
const response = await fetcher();
if (response.ok && response.data) {
setData(response.data);
setError(null);
} else {
setData(null);
setError(response.error || { message: 'Unknown error' });
}
} catch (err) {
setData(null);
setError({
message: err instanceof Error ? err.message : 'Unknown error',
code: 'EXCEPTION',
});
} finally {
setLoading(false);
setRefetching(false);
}
},
[fetcher]
);
// Initial fetch
useEffect(() => {
if (!skip) {
fetchData(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
// Refetch when network comes back online
useEffect(() => {
if (refetchOnReconnect && isOnline && error && isNetworkError(error)) {
fetchData(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOnline, refetchOnReconnect, error]);
// Polling (if enabled)
useEffect(() => {
if (pollInterval > 0 && !skip) {
const interval = setInterval(() => {
if (isOnline) {
fetchData(true);
}
}, pollInterval);
return () => clearInterval(interval);
}
}, [pollInterval, skip, isOnline, fetchData]);
// Manual refetch
const refetch = useCallback(async () => {
await fetchData(true);
}, [fetchData]);
// Computed values
const isOfflineError = error ? isNetworkError(error) : false;
const errorMessage = error
? offlineMessage && isOfflineError
? offlineMessage
: getNetworkErrorMessage(error)
: null;
return {
data,
loading,
error,
refetch,
refetching,
errorMessage,
isOfflineError,
};
}
/**
* Simpler hook for offline-aware mutations (create, update, delete)
*
* @example
* function MyComponent() {
* const { mutate, loading, error } = useOfflineAwareMutation(
* (data) => offlineAwareApi.createBeneficiary(data)
* );
*
* const handleSubmit = async () => {
* const result = await mutate(formData);
* if (result.ok) {
* // Success
* }
* };
* }
*/
export function useOfflineAwareMutation<TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<ApiResponse<TData>>,
options: {
offlineMessage?: string;
onSuccess?: (data: TData) => void;
onError?: (error: ApiError) => void;
} = {}
) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const { isOnline } = useNetworkStatus();
const mutate = useCallback(
async (variables: TVariables): Promise<ApiResponse<TData>> => {
setLoading(true);
setError(null);
try {
const response = await mutationFn(variables);
if (response.ok && response.data) {
options.onSuccess?.(response.data);
} else {
setError(response.error || { message: 'Unknown error' });
options.onError?.(response.error || { message: 'Unknown error' });
}
return response;
} catch (err) {
const apiError: ApiError = {
message: err instanceof Error ? err.message : 'Unknown error',
code: 'EXCEPTION',
};
setError(apiError);
options.onError?.(apiError);
return { ok: false, error: apiError };
} finally {
setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[mutationFn, options.onSuccess, options.onError]
);
const isOfflineError = error ? isNetworkError(error) : false;
const errorMessage = error
? options.offlineMessage && isOfflineError
? options.offlineMessage
: getNetworkErrorMessage(error)
: null;
return {
mutate,
loading,
error,
errorMessage,
isOfflineError,
isOnline,
};
}