diff --git a/__tests__/components/LoadingOverlay.test.tsx b/__tests__/components/LoadingOverlay.test.tsx
new file mode 100644
index 0000000..8e54502
--- /dev/null
+++ b/__tests__/components/LoadingOverlay.test.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import {
+ LoadingOverlay,
+ InlineLoadingOverlay,
+ LoadingCardOverlay,
+} from '@/components/ui/LoadingOverlay';
+
+describe('LoadingOverlay', () => {
+ it('renders nothing when visible is false', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders modal when visible is true', () => {
+ const { getByText } = render();
+ expect(getByText('Loading...')).toBeTruthy();
+ });
+
+ it('renders custom message', () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText('Saving...')).toBeTruthy();
+ });
+});
+
+describe('InlineLoadingOverlay', () => {
+ it('renders nothing when visible is false', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders overlay when visible is true', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeTruthy();
+ });
+
+ it('renders with message', () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText('Loading data...')).toBeTruthy();
+ });
+});
+
+describe('LoadingCardOverlay', () => {
+ it('renders nothing when visible is false', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders overlay when visible is true', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/ScreenLoading.test.tsx b/__tests__/components/ScreenLoading.test.tsx
new file mode 100644
index 0000000..fc05994
--- /dev/null
+++ b/__tests__/components/ScreenLoading.test.tsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { Text, View } from 'react-native';
+import { render, fireEvent } from '@testing-library/react-native';
+import {
+ ScreenLoading,
+ RefreshableScreen,
+ EmptyState,
+ LoadingButtonState,
+} from '@/components/ui/ScreenLoading';
+
+describe('ScreenLoading', () => {
+ it('renders children when not loading and no error', () => {
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Content')).toBeTruthy();
+ });
+
+ it('renders loading state when isLoading is true', () => {
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Loading...')).toBeTruthy();
+ });
+
+ it('renders custom loading message', () => {
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Fetching data...')).toBeTruthy();
+ });
+
+ it('renders error state when error is provided', () => {
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Something went wrong')).toBeTruthy();
+ expect(getByText('Network error')).toBeTruthy();
+ });
+
+ it('renders retry button when onRetry is provided', () => {
+ const onRetry = jest.fn();
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Try Again')).toBeTruthy();
+ });
+
+ it('calls onRetry when retry button is pressed', () => {
+ const onRetry = jest.fn();
+ const { getByText } = render(
+
+ Content
+
+ );
+ fireEvent.press(getByText('Try Again'));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render retry button when onRetry is not provided', () => {
+ const { queryByText } = render(
+
+ Content
+
+ );
+ expect(queryByText('Try Again')).toBeNull();
+ });
+});
+
+describe('RefreshableScreen', () => {
+ it('renders children', () => {
+ const onRefresh = jest.fn();
+ const { getByText } = render(
+
+ Content
+
+ );
+ expect(getByText('Content')).toBeTruthy();
+ });
+});
+
+describe('EmptyState', () => {
+ it('renders title and description', () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText('No items')).toBeTruthy();
+ expect(getByText('Add some items to get started')).toBeTruthy();
+ });
+
+ it('renders action button when provided', () => {
+ const onAction = jest.fn();
+ const { getByText } = render(
+
+ );
+ expect(getByText('Add Item')).toBeTruthy();
+ });
+
+ it('calls onAction when action button is pressed', () => {
+ const onAction = jest.fn();
+ const { getByText } = render(
+
+ );
+ fireEvent.press(getByText('Add Item'));
+ expect(onAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render action button when onAction is not provided', () => {
+ const { queryByText } = render(
+
+ );
+ expect(queryByText('Add Item')).toBeNull();
+ });
+});
+
+describe('LoadingButtonState', () => {
+ it('renders children when not loading', () => {
+ const { getByText } = render(
+
+ Submit
+
+ );
+ expect(getByText('Submit')).toBeTruthy();
+ });
+
+ it('renders loading state when isLoading is true', () => {
+ const { getByText } = render(
+
+ Submit
+
+ );
+ expect(getByText('Loading...')).toBeTruthy();
+ });
+
+ it('renders custom loading text', () => {
+ const { getByText } = render(
+
+ Submit
+
+ );
+ expect(getByText('Saving...')).toBeTruthy();
+ });
+});
diff --git a/__tests__/components/Skeleton.test.tsx b/__tests__/components/Skeleton.test.tsx
new file mode 100644
index 0000000..bf136d5
--- /dev/null
+++ b/__tests__/components/Skeleton.test.tsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import {
+ Skeleton,
+ SkeletonText,
+ SkeletonAvatar,
+ SkeletonCard,
+ SkeletonListItem,
+ SkeletonBeneficiaryCard,
+ SkeletonSensorCard,
+} from '@/components/ui/Skeleton';
+
+describe('Skeleton', () => {
+ it('renders with default props', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom width and height', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom border radius', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with percentage width', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonText', () => {
+ it('renders single line by default', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders multiple lines', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom line height', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom last line width', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonAvatar', () => {
+ it('renders with default size', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom size', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonCard', () => {
+ it('renders with default height', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom height', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonListItem', () => {
+ it('renders with default props', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom avatar size', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders with custom number of lines', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonBeneficiaryCard', () => {
+ it('renders beneficiary card skeleton', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
+
+describe('SkeletonSensorCard', () => {
+ it('renders sensor card skeleton', () => {
+ const { toJSON } = render();
+ const tree = toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
diff --git a/components/ui/LoadingOverlay.tsx b/components/ui/LoadingOverlay.tsx
new file mode 100644
index 0000000..08a57f7
--- /dev/null
+++ b/components/ui/LoadingOverlay.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ Modal,
+ ViewStyle,
+} from 'react-native';
+import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
+
+interface LoadingOverlayProps {
+ visible: boolean;
+ message?: string;
+ transparent?: boolean;
+}
+
+/**
+ * Full-screen modal loading overlay
+ * Use for operations that block the entire UI
+ */
+export function LoadingOverlay({
+ visible,
+ message = 'Loading...',
+ transparent = true,
+}: LoadingOverlayProps) {
+ if (!visible) return null;
+
+ return (
+
+
+
+
+ {message && {message}}
+
+
+
+ );
+}
+
+interface InlineLoadingOverlayProps {
+ visible: boolean;
+ message?: string;
+ style?: ViewStyle;
+}
+
+/**
+ * Inline loading overlay that covers its parent container
+ * Use for loading states within a specific section
+ */
+export function InlineLoadingOverlay({
+ visible,
+ message,
+ style,
+}: InlineLoadingOverlayProps) {
+ if (!visible) return null;
+
+ return (
+
+
+ {message && {message}}
+
+ );
+}
+
+interface LoadingCardOverlayProps {
+ visible: boolean;
+}
+
+/**
+ * Card-style loading overlay
+ * Use for overlay on cards or smaller components
+ */
+export function LoadingCardOverlay({ visible }: LoadingCardOverlayProps) {
+ if (!visible) return null;
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ backgroundColor: AppColors.overlay,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ container: {
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.xl,
+ padding: Spacing.xl,
+ alignItems: 'center',
+ minWidth: 150,
+ ...Shadows.lg,
+ },
+ message: {
+ marginTop: Spacing.md,
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ },
+ inlineOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: BorderRadius.lg,
+ },
+ inlineMessage: {
+ marginTop: Spacing.sm,
+ fontSize: FontSizes.sm,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ },
+ cardOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: BorderRadius.lg,
+ },
+});
diff --git a/components/ui/ScreenLoading.tsx b/components/ui/ScreenLoading.tsx
new file mode 100644
index 0000000..72b0efa
--- /dev/null
+++ b/components/ui/ScreenLoading.tsx
@@ -0,0 +1,272 @@
+import React from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ ActivityIndicator,
+ TouchableOpacity,
+ ScrollView,
+ RefreshControl,
+} from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ AppColors,
+ BorderRadius,
+ FontSizes,
+ Spacing,
+ FontWeights,
+ Shadows,
+} from '@/constants/theme';
+
+interface ScreenLoadingProps {
+ isLoading: boolean;
+ error?: string | null;
+ children: React.ReactNode;
+ onRetry?: () => void;
+ loadingMessage?: string;
+ fullScreen?: boolean;
+}
+
+/**
+ * Wrapper component for screens with loading/error/content states
+ * Provides consistent loading and error UI across the app
+ */
+export function ScreenLoading({
+ isLoading,
+ error,
+ children,
+ onRetry,
+ loadingMessage = 'Loading...',
+ fullScreen = true,
+}: ScreenLoadingProps) {
+ if (isLoading) {
+ return (
+
+
+ {loadingMessage}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+ Something went wrong
+ {error}
+ {onRetry && (
+
+
+ Try Again
+
+ )}
+
+ );
+ }
+
+ return <>{children}>;
+}
+
+interface RefreshableScreenProps {
+ isRefreshing: boolean;
+ onRefresh: () => void;
+ children: React.ReactNode;
+ contentContainerStyle?: object;
+ showsVerticalScrollIndicator?: boolean;
+}
+
+/**
+ * ScrollView with built-in pull-to-refresh
+ * Provides consistent refresh behavior across the app
+ */
+export function RefreshableScreen({
+ isRefreshing,
+ onRefresh,
+ children,
+ contentContainerStyle,
+ showsVerticalScrollIndicator = false,
+}: RefreshableScreenProps) {
+ return (
+
+ }
+ >
+ {children}
+
+ );
+}
+
+interface EmptyStateProps {
+ icon?: keyof typeof Ionicons.glyphMap;
+ title: string;
+ description?: string;
+ actionLabel?: string;
+ onAction?: () => void;
+}
+
+/**
+ * Empty state component for when there's no content to display
+ */
+export function EmptyState({
+ icon = 'folder-open-outline',
+ title,
+ description,
+ actionLabel,
+ onAction,
+}: EmptyStateProps) {
+ return (
+
+
+
+
+ {title}
+ {description && {description}}
+ {actionLabel && onAction && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
+
+interface LoadingButtonStateProps {
+ isLoading: boolean;
+ loadingText?: string;
+ children: React.ReactNode;
+}
+
+/**
+ * Wrapper for button content that shows loading state
+ */
+export function LoadingButtonState({
+ isLoading,
+ loadingText = 'Loading...',
+ children,
+}: LoadingButtonStateProps) {
+ if (isLoading) {
+ return (
+
+
+ {loadingText && {loadingText}}
+
+ );
+ }
+
+ return <>{children}>;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: Spacing.xl,
+ },
+ fullScreen: {
+ flex: 1,
+ backgroundColor: AppColors.background,
+ },
+ loadingMessage: {
+ marginTop: Spacing.md,
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ },
+ errorIcon: {
+ marginBottom: Spacing.md,
+ },
+ errorTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: FontWeights.semibold,
+ color: AppColors.textPrimary,
+ marginBottom: Spacing.sm,
+ textAlign: 'center',
+ },
+ errorMessage: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ marginBottom: Spacing.lg,
+ paddingHorizontal: Spacing.lg,
+ },
+ retryButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: AppColors.primary,
+ paddingHorizontal: Spacing.lg,
+ paddingVertical: Spacing.md,
+ borderRadius: BorderRadius.lg,
+ gap: Spacing.sm,
+ ...Shadows.primary,
+ },
+ retryText: {
+ fontSize: FontSizes.base,
+ fontWeight: FontWeights.semibold,
+ color: AppColors.white,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: Spacing.xl,
+ },
+ emptyIcon: {
+ width: 80,
+ height: 80,
+ borderRadius: 40,
+ backgroundColor: AppColors.surfaceSecondary,
+ justifyContent: 'center',
+ alignItems: 'center',
+ marginBottom: Spacing.lg,
+ },
+ emptyTitle: {
+ fontSize: FontSizes.lg,
+ fontWeight: FontWeights.semibold,
+ color: AppColors.textPrimary,
+ textAlign: 'center',
+ marginBottom: Spacing.sm,
+ },
+ emptyDescription: {
+ fontSize: FontSizes.base,
+ color: AppColors.textSecondary,
+ textAlign: 'center',
+ marginBottom: Spacing.lg,
+ paddingHorizontal: Spacing.lg,
+ },
+ emptyAction: {
+ backgroundColor: AppColors.primary,
+ paddingHorizontal: Spacing.lg,
+ paddingVertical: Spacing.md,
+ borderRadius: BorderRadius.lg,
+ },
+ emptyActionText: {
+ fontSize: FontSizes.base,
+ fontWeight: FontWeights.semibold,
+ color: AppColors.white,
+ },
+ buttonLoading: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.sm,
+ },
+ buttonLoadingText: {
+ fontSize: FontSizes.base,
+ color: AppColors.white,
+ fontWeight: FontWeights.medium,
+ },
+});
diff --git a/components/ui/Skeleton.tsx b/components/ui/Skeleton.tsx
new file mode 100644
index 0000000..3269621
--- /dev/null
+++ b/components/ui/Skeleton.tsx
@@ -0,0 +1,251 @@
+import React, { useEffect, useRef } from 'react';
+import {
+ View,
+ StyleSheet,
+ Animated,
+ ViewStyle,
+ Easing,
+ DimensionValue,
+} from 'react-native';
+import { AppColors, BorderRadius, Spacing } from '@/constants/theme';
+
+interface SkeletonProps {
+ width?: DimensionValue;
+ height?: DimensionValue;
+ borderRadius?: number;
+ style?: ViewStyle;
+}
+
+/**
+ * Animated skeleton loader for placeholder content
+ * Uses a shimmer effect to indicate loading state
+ */
+export function Skeleton({
+ width = '100%',
+ height = 20,
+ borderRadius = BorderRadius.sm,
+ style,
+}: SkeletonProps) {
+ const shimmerAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ const animation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(shimmerAnim, {
+ toValue: 1,
+ duration: 1000,
+ easing: Easing.ease,
+ useNativeDriver: true,
+ }),
+ Animated.timing(shimmerAnim, {
+ toValue: 0,
+ duration: 1000,
+ easing: Easing.ease,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+
+ animation.start();
+
+ return () => animation.stop();
+ }, [shimmerAnim]);
+
+ const animatedOpacity = shimmerAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.3, 0.7],
+ });
+
+ return (
+
+ );
+}
+
+/**
+ * Skeleton for text lines - common use case
+ */
+export function SkeletonText({
+ lines = 1,
+ lineHeight = 16,
+ spacing = Spacing.sm,
+ lastLineWidth = '60%',
+ style,
+}: {
+ lines?: number;
+ lineHeight?: number;
+ spacing?: number;
+ lastLineWidth?: DimensionValue;
+ style?: ViewStyle;
+}) {
+ return (
+
+ {Array.from({ length: lines }).map((_, index) => (
+ 1 ? lastLineWidth : '100%'}
+ style={index < lines - 1 ? { marginBottom: spacing } : undefined}
+ />
+ ))}
+
+ );
+}
+
+/**
+ * Skeleton for avatar/circular elements
+ */
+export function SkeletonAvatar({
+ size = 48,
+ style,
+}: {
+ size?: number;
+ style?: ViewStyle;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * Skeleton for a card-like element
+ */
+export function SkeletonCard({
+ height = 100,
+ style,
+}: {
+ height?: DimensionValue;
+ style?: ViewStyle;
+}) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Skeleton for list items with avatar and text
+ */
+export function SkeletonListItem({
+ avatarSize = 48,
+ lines = 2,
+ style,
+}: {
+ avatarSize?: number;
+ lines?: number;
+ style?: ViewStyle;
+}) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Skeleton for beneficiary cards
+ */
+export function SkeletonBeneficiaryCard({ style }: { style?: ViewStyle }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Skeleton for sensor health cards
+ */
+export function SkeletonSensorCard({ style }: { style?: ViewStyle }) {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ skeleton: {
+ backgroundColor: AppColors.surfaceSecondary,
+ },
+ card: {
+ marginBottom: Spacing.md,
+ },
+ listItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: Spacing.md,
+ paddingHorizontal: Spacing.md,
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.lg,
+ marginBottom: Spacing.sm,
+ },
+ listItemContent: {
+ flex: 1,
+ marginLeft: Spacing.md,
+ },
+ beneficiaryCard: {
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.xl,
+ padding: Spacing.lg,
+ marginBottom: Spacing.md,
+ },
+ beneficiaryHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: Spacing.md,
+ },
+ beneficiaryInfo: {
+ flex: 1,
+ marginLeft: Spacing.md,
+ },
+ beneficiaryActions: {
+ flexDirection: 'row',
+ gap: Spacing.sm,
+ },
+ sensorCard: {
+ backgroundColor: AppColors.surface,
+ borderRadius: BorderRadius.lg,
+ padding: Spacing.md,
+ marginBottom: Spacing.sm,
+ },
+ sensorHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Spacing.sm,
+ },
+});
diff --git a/hooks/__tests__/useLoadingState.test.ts b/hooks/__tests__/useLoadingState.test.ts
new file mode 100644
index 0000000..27cb26b
--- /dev/null
+++ b/hooks/__tests__/useLoadingState.test.ts
@@ -0,0 +1,284 @@
+import { renderHook, act, cleanup } from '@testing-library/react-native';
+import {
+ useLoadingState,
+ useSimpleLoading,
+ useMultipleLoadingStates,
+} from '@/hooks/useLoadingState';
+
+// Cleanup after each test to prevent unmounted renderer issues
+afterEach(() => {
+ cleanup();
+});
+
+describe('useLoadingState', () => {
+ it('initializes with default state', () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ expect(result.current.data).toBeNull();
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.isRefreshing).toBe(false);
+ });
+
+ it('initializes with provided initial data', () => {
+ const { result } = renderHook(() =>
+ useLoadingState({ initialData: { id: 1, name: 'Test' } })
+ );
+
+ expect(result.current.data).toEqual({ id: 1, name: 'Test' });
+ });
+
+ it('setData updates data and clears error', () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ act(() => {
+ result.current.setError('Previous error');
+ });
+
+ act(() => {
+ result.current.setData('new data');
+ });
+
+ expect(result.current.data).toBe('new data');
+ expect(result.current.error).toBeNull();
+ });
+
+ it('setLoading updates loading state', () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ act(() => {
+ result.current.setLoading(true);
+ });
+
+ expect(result.current.isLoading).toBe(true);
+
+ act(() => {
+ result.current.setLoading(false);
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('setError updates error and clears loading states', () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ act(() => {
+ result.current.setLoading(true);
+ });
+
+ act(() => {
+ result.current.setError('Something went wrong');
+ });
+
+ expect(result.current.error).toBe('Something went wrong');
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isRefreshing).toBe(false);
+ });
+
+ it('setRefreshing updates refreshing state', () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ act(() => {
+ result.current.setRefreshing(true);
+ });
+
+ expect(result.current.isRefreshing).toBe(true);
+ });
+
+ it('reset restores initial state', () => {
+ const { result } = renderHook(() =>
+ useLoadingState({ initialData: 'initial' })
+ );
+
+ act(() => {
+ result.current.setData('modified');
+ result.current.setLoading(true);
+ result.current.setError('error');
+ });
+
+ act(() => {
+ result.current.reset();
+ });
+
+ expect(result.current.data).toBe('initial');
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ expect(result.current.isRefreshing).toBe(false);
+ });
+
+ it('execute handles successful async function', async () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ await act(async () => {
+ await result.current.execute(async () => {
+ return 'success';
+ });
+ });
+
+ expect(result.current.data).toBe('success');
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.error).toBeNull();
+ });
+
+ it('execute handles async function error', async () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ await act(async () => {
+ await result.current.execute(async () => {
+ throw new Error('Network error');
+ });
+ });
+
+ expect(result.current.error).toBe('Network error');
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('execute with refresh option returns data', async () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ await act(async () => {
+ await result.current.execute(
+ async () => {
+ return 'refreshed data';
+ },
+ { refresh: true }
+ );
+ });
+
+ expect(result.current.data).toBe('refreshed data');
+ expect(result.current.isRefreshing).toBe(false);
+ });
+
+ it('execute uses transform function', async () => {
+ const { result } = renderHook(() => useLoadingState());
+
+ await act(async () => {
+ await result.current.execute(
+ async () => {
+ return { value: 'raw' };
+ },
+ { transform: (res) => res.value.toUpperCase() }
+ );
+ });
+
+ expect(result.current.data).toBe('RAW');
+ });
+});
+
+describe('useSimpleLoading', () => {
+ it('initializes with default state', () => {
+ const { result } = renderHook(() => useSimpleLoading());
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('initializes with provided initial state', () => {
+ const { result } = renderHook(() => useSimpleLoading(true));
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('startLoading sets isLoading to true', () => {
+ const { result } = renderHook(() => useSimpleLoading());
+
+ act(() => {
+ result.current.startLoading();
+ });
+
+ expect(result.current.isLoading).toBe(true);
+ });
+
+ it('stopLoading sets isLoading to false', () => {
+ const { result } = renderHook(() => useSimpleLoading(true));
+
+ act(() => {
+ result.current.stopLoading();
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('toggleLoading toggles isLoading', () => {
+ const { result } = renderHook(() => useSimpleLoading(false));
+
+ act(() => {
+ result.current.toggleLoading();
+ });
+ expect(result.current.isLoading).toBe(true);
+
+ act(() => {
+ result.current.toggleLoading();
+ });
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('withLoading wraps async function', async () => {
+ const { result } = renderHook(() => useSimpleLoading());
+
+ await act(async () => {
+ const returnValue = await result.current.withLoading(async () => {
+ return 'done';
+ });
+
+ expect(returnValue).toBe('done');
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+});
+
+describe('useMultipleLoadingStates', () => {
+ it('initializes with empty loading states', () => {
+ const { result } = renderHook(() => useMultipleLoadingStates());
+
+ expect(result.current.loadingStates).toEqual({});
+ expect(result.current.anyLoading).toBe(false);
+ });
+
+ it('setLoading updates individual loading state', () => {
+ const { result } = renderHook(() => useMultipleLoadingStates());
+
+ act(() => {
+ result.current.setLoading('item-1', true);
+ });
+
+ expect(result.current.isLoading('item-1')).toBe(true);
+ expect(result.current.isLoading('item-2')).toBe(false);
+ expect(result.current.anyLoading).toBe(true);
+ });
+
+ it('tracks multiple loading states', () => {
+ const { result } = renderHook(() => useMultipleLoadingStates());
+
+ act(() => {
+ result.current.setLoading('item-1', true);
+ result.current.setLoading('item-2', true);
+ });
+
+ expect(result.current.isLoading('item-1')).toBe(true);
+ expect(result.current.isLoading('item-2')).toBe(true);
+ expect(result.current.anyLoading).toBe(true);
+
+ act(() => {
+ result.current.setLoading('item-1', false);
+ });
+
+ expect(result.current.isLoading('item-1')).toBe(false);
+ expect(result.current.isLoading('item-2')).toBe(true);
+ expect(result.current.anyLoading).toBe(true);
+ });
+
+ it('clearAll clears all loading states', () => {
+ const { result } = renderHook(() => useMultipleLoadingStates());
+
+ act(() => {
+ result.current.setLoading('item-1', true);
+ result.current.setLoading('item-2', true);
+ });
+
+ act(() => {
+ result.current.clearAll();
+ });
+
+ expect(result.current.loadingStates).toEqual({});
+ expect(result.current.anyLoading).toBe(false);
+ });
+});
diff --git a/hooks/useLoadingState.ts b/hooks/useLoadingState.ts
new file mode 100644
index 0000000..1951930
--- /dev/null
+++ b/hooks/useLoadingState.ts
@@ -0,0 +1,197 @@
+import { useState, useCallback } from 'react';
+
+interface LoadingState {
+ data: T | null;
+ isLoading: boolean;
+ error: string | null;
+ isRefreshing: boolean;
+}
+
+interface UseLoadingStateOptions {
+ initialData?: T | null;
+}
+
+interface UseLoadingStateReturn extends LoadingState {
+ setData: (data: T | null) => void;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ setRefreshing: (refreshing: boolean) => void;
+ reset: () => void;
+ execute: (
+ asyncFn: () => Promise,
+ options?: { refresh?: boolean; transform?: (result: R) => T }
+ ) => Promise;
+}
+
+/**
+ * Custom hook for managing loading states
+ * Provides consistent loading, error, and data state management
+ */
+export function useLoadingState(
+ options: UseLoadingStateOptions = {}
+): UseLoadingStateReturn {
+ const { initialData = null } = options;
+
+ const [state, setState] = useState>({
+ data: initialData,
+ isLoading: false,
+ error: null,
+ isRefreshing: false,
+ });
+
+ const setData = useCallback((data: T | null) => {
+ setState((prev) => ({ ...prev, data, error: null }));
+ }, []);
+
+ const setLoading = useCallback((isLoading: boolean) => {
+ setState((prev) => ({ ...prev, isLoading }));
+ }, []);
+
+ const setError = useCallback((error: string | null) => {
+ setState((prev) => ({ ...prev, error, isLoading: false, isRefreshing: false }));
+ }, []);
+
+ const setRefreshing = useCallback((isRefreshing: boolean) => {
+ setState((prev) => ({ ...prev, isRefreshing }));
+ }, []);
+
+ const reset = useCallback(() => {
+ setState({
+ data: initialData,
+ isLoading: false,
+ error: null,
+ isRefreshing: false,
+ });
+ }, [initialData]);
+
+ /**
+ * Execute an async function with automatic loading state management
+ * @param asyncFn - The async function to execute
+ * @param options.refresh - If true, sets isRefreshing instead of isLoading
+ * @param options.transform - Optional function to transform the result before setting data
+ */
+ const execute = useCallback(
+ async (
+ asyncFn: () => Promise,
+ execOptions?: { refresh?: boolean; transform?: (result: R) => T }
+ ): Promise => {
+ const { refresh = false, transform } = execOptions || {};
+
+ try {
+ if (refresh) {
+ setState((prev) => ({ ...prev, isRefreshing: true, error: null }));
+ } else {
+ setState((prev) => ({ ...prev, isLoading: true, error: null }));
+ }
+
+ const result = await asyncFn();
+
+ // Transform and set data if transform function provided
+ if (transform) {
+ const transformedData = transform(result);
+ setState((prev) => ({
+ ...prev,
+ data: transformedData,
+ isLoading: false,
+ isRefreshing: false,
+ }));
+ } else {
+ // If no transform, try to set result as data (if types match)
+ setState((prev) => ({
+ ...prev,
+ data: result as unknown as T,
+ isLoading: false,
+ isRefreshing: false,
+ }));
+ }
+
+ return result;
+ } catch (err) {
+ const errorMessage =
+ err instanceof Error ? err.message : 'An error occurred';
+ setState((prev) => ({
+ ...prev,
+ error: errorMessage,
+ isLoading: false,
+ isRefreshing: false,
+ }));
+ return undefined;
+ }
+ },
+ []
+ );
+
+ return {
+ ...state,
+ setData,
+ setLoading,
+ setError,
+ setRefreshing,
+ reset,
+ execute,
+ };
+}
+
+/**
+ * Simple boolean loading state hook
+ * For cases where you just need a loading flag
+ */
+export function useSimpleLoading(initialState = false) {
+ const [isLoading, setIsLoading] = useState(initialState);
+
+ const startLoading = useCallback(() => setIsLoading(true), []);
+ const stopLoading = useCallback(() => setIsLoading(false), []);
+ const toggleLoading = useCallback(() => setIsLoading((prev) => !prev), []);
+
+ const withLoading = useCallback(
+ async (asyncFn: () => Promise): Promise => {
+ setIsLoading(true);
+ try {
+ return await asyncFn();
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ []
+ );
+
+ return {
+ isLoading,
+ setIsLoading,
+ startLoading,
+ stopLoading,
+ toggleLoading,
+ withLoading,
+ };
+}
+
+/**
+ * Hook for managing multiple loading states by key
+ * Useful for list items that can have individual loading states
+ */
+export function useMultipleLoadingStates() {
+ const [loadingStates, setLoadingStates] = useState>({});
+
+ const setLoading = useCallback((key: string, loading: boolean) => {
+ setLoadingStates((prev) => ({ ...prev, [key]: loading }));
+ }, []);
+
+ const isLoading = useCallback(
+ (key: string) => loadingStates[key] || false,
+ [loadingStates]
+ );
+
+ const anyLoading = Object.values(loadingStates).some(Boolean);
+
+ const clearAll = useCallback(() => {
+ setLoadingStates({});
+ }, []);
+
+ return {
+ loadingStates,
+ setLoading,
+ isLoading,
+ anyLoading,
+ clearAll,
+ };
+}