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>
277 lines
6.6 KiB
TypeScript
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,
|
|
};
|
|
}
|