UTA DevHub

Shared Custom Hooks

Essential React hooks for common patterns like async operations, device state, and UI interactions

Shared Custom Hooks

Production-ready custom hooks for common React Native patterns and interactions

Overview

This collection provides type-safe, reusable hooks that solve common problems in React Native development. Each hook is designed for performance, testability, and ease of use.

Implementation Files

Async Operations

// core/shared/hooks/useAsync.ts
import { useState, useEffect, useCallback, useRef } from 'react';
 
interface AsyncState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}
 
interface UseAsyncOptions {
  immediate?: boolean;
  onSuccess?: (data: any) => void;
  onError?: (error: Error) => void;
}
 
export function useAsync<T = any>(
  asyncFunction: () => Promise<T>,
  deps: React.DependencyList = [],
  options: UseAsyncOptions = {}
) {
  const { immediate = true, onSuccess, onError } = options;
  const [state, setState] = useState<AsyncState<T>>({
    data: null,
    loading: immediate,
    error: null,
  });
 
  const isMountedRef = useRef(true);
  const latestAsyncFunction = useRef(asyncFunction);
 
  useEffect(() => {
    latestAsyncFunction.current = asyncFunction;
  });
 
  useEffect(() => {
    return () => {
      isMountedRef.current = false;
    };
  }, []);
 
  const execute = useCallback(async () => {
    if (!isMountedRef.current) return;
 
    setState(prev => ({ ...prev, loading: true, error: null }));
 
    try {
      const data = await latestAsyncFunction.current();
      
      if (isMountedRef.current) {
        setState({ data, loading: false, error: null });
        onSuccess?.(data);
      }
      
      return data;
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));
      
      if (isMountedRef.current) {
        setState(prev => ({ ...prev, loading: false, error: err }));
        onError?.(err);
      }
      
      throw err;
    }
  }, deps);
 
  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);
 
  const reset = useCallback(() => {
    setState({ data: null, loading: false, error: null });
  }, []);
 
  return {
    ...state,
    execute,
    reset,
  };
}

Device and Platform Hooks

// core/shared/hooks/useDeviceInfo.ts
import { useState, useEffect } from 'react';
import { Dimensions, Platform, StatusBar } from 'react-native';
import { getStatusBarHeight } from 'react-native-status-bar-height';
 
interface DeviceInfo {
  width: number;
  height: number;
  isLandscape: boolean;
  isTablet: boolean;
  platform: 'ios' | 'android' | 'web';
  statusBarHeight: number;
  safeAreaTop: number;
  safeAreaBottom: number;
}
 
export function useDeviceInfo(): DeviceInfo {
  const [dimensions, setDimensions] = useState(Dimensions.get('window'));
 
  useEffect(() => {
    const subscription = Dimensions.addEventListener('change', ({ window }) => {
      setDimensions(window);
    });
 
    return () => subscription?.remove();
  }, []);
 
  const isLandscape = dimensions.width > dimensions.height;
  const isTablet = Math.min(dimensions.width, dimensions.height) >= 600;
  const statusBarHeight = getStatusBarHeight();
 
  return {
    width: dimensions.width,
    height: dimensions.height,
    isLandscape,
    isTablet,
    platform: Platform.OS as 'ios' | 'android' | 'web',
    statusBarHeight,
    safeAreaTop: statusBarHeight,
    safeAreaBottom: Platform.OS === 'ios' ? 34 : 0, // iPhone X+ safe area
  };
}

Network Status Hook

// core/shared/hooks/useNetworkStatus.ts
import { useState, useEffect } from 'react';
import NetInfo, { NetInfoState } from '@react-native-async-storage/async-storage';
 
interface NetworkStatus {
  isConnected: boolean;
  isInternetReachable: boolean | null;
  type: string | null;
  isWifiEnabled: boolean;
  details: any;
}
 
export function useNetworkStatus(): NetworkStatus {
  const [networkStatus, setNetworkStatus] = useState<NetworkStatus>({
    isConnected: true,
    isInternetReachable: null,
    type: null,
    isWifiEnabled: false,
    details: null,
  });
 
  useEffect(() => {
    const unsubscribe = NetInfo.addEventListener((state: NetInfoState) => {
      setNetworkStatus({
        isConnected: state.isConnected ?? false,
        isInternetReachable: state.isInternetReachable,
        type: state.type,
        isWifiEnabled: state.type === 'wifi',
        details: state.details,
      });
    });
 
    return unsubscribe;
  }, []);
 
  return networkStatus;
}

Keyboard Handling

// core/shared/hooks/useKeyboard.ts
import { useState, useEffect } from 'react';
import { Keyboard, KeyboardEvent } from 'react-native';
 
interface KeyboardInfo {
  isVisible: boolean;
  height: number;
}
 
export function useKeyboard(): KeyboardInfo {
  const [keyboardInfo, setKeyboardInfo] = useState<KeyboardInfo>({
    isVisible: false,
    height: 0,
  });
 
  useEffect(() => {
    const showSubscription = Keyboard.addListener('keyboardDidShow', (e: KeyboardEvent) => {
      setKeyboardInfo({
        isVisible: true,
        height: e.endCoordinates.height,
      });
    });
 
    const hideSubscription = Keyboard.addListener('keyboardDidHide', () => {
      setKeyboardInfo({
        isVisible: false,
        height: 0,
      });
    });
 
    return () => {
      showSubscription.remove();
      hideSubscription.remove();
    };
  }, []);
 
  return keyboardInfo;
}

Debounced Value

// core/shared/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
 
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
 
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
 
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
 
  return debouncedValue;
}
 
// Alternative with cancel capability
export function useDebouncedCallback<T extends (...args: any[]) => any>(
  callback: T,
  delay: number
): [T, () => void] {
  const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
 
  const debouncedCallback = ((...args: Parameters<T>) => {
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
 
    const newTimeoutId = setTimeout(() => {
      callback(...args);
    }, delay);
 
    setTimeoutId(newTimeoutId);
  }) as T;
 
  const cancel = () => {
    if (timeoutId) {
      clearTimeout(timeoutId);
      setTimeoutId(null);
    }
  };
 
  useEffect(() => {
    return cancel;
  }, []);
 
  return [debouncedCallback, cancel];
}

Previous Value

// core/shared/hooks/usePrevious.ts
import { useRef, useEffect } from 'react';
 
export function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  
  useEffect(() => {
    ref.current = value;
  });
  
  return ref.current;
}

Local Storage

// core/shared/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
 
export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => Promise<void>, boolean, Error | null] {
  const [storedValue, setStoredValue] = useState<T>(initialValue);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const loadStoredValue = async () => {
      try {
        setLoading(true);
        const item = await AsyncStorage.getItem(key);
        if (item !== null) {
          setStoredValue(JSON.parse(item));
        }
      } catch (err) {
        setError(err instanceof Error ? err : new Error(String(err)));
      } finally {
        setLoading(false);
      }
    };
 
    loadStoredValue();
  }, [key]);
 
  const setValue = async (value: T) => {
    try {
      setStoredValue(value);
      await AsyncStorage.setItem(key, JSON.stringify(value));
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err : new Error(String(err)));
    }
  };
 
  return [storedValue, setValue, loading, error];
}

Form Handling

// core/shared/hooks/useForm.ts
import { useState, useCallback } from 'react';
 
interface ValidationRule<T> {
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  pattern?: RegExp;
  custom?: (value: T) => string | null;
}
 
interface FormConfig<T> {
  initialValues: T;
  validationRules?: Partial<Record<keyof T, ValidationRule<T[keyof T]>>>;
  onSubmit?: (values: T) => void | Promise<void>;
}
 
interface FormState<T> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
  isSubmitting: boolean;
  isValid: boolean;
}
 
export function useForm<T extends Record<string, any>>({
  initialValues,
  validationRules = {},
  onSubmit,
}: FormConfig<T>) {
  const [state, setState] = useState<FormState<T>>({
    values: initialValues,
    errors: {},
    touched: {},
    isSubmitting: false,
    isValid: true,
  });
 
  const validateField = useCallback((name: keyof T, value: T[keyof T]): string | null => {
    const rules = validationRules[name];
    if (!rules) return null;
 
    if (rules.required && (!value || (typeof value === 'string' && value.trim() === ''))) {
      return 'This field is required';
    }
 
    if (rules.minLength && typeof value === 'string' && value.length < rules.minLength) {
      return `Minimum length is ${rules.minLength} characters`;
    }
 
    if (rules.maxLength && typeof value === 'string' && value.length > rules.maxLength) {
      return `Maximum length is ${rules.maxLength} characters`;
    }
 
    if (rules.pattern && typeof value === 'string' && !rules.pattern.test(value)) {
      return 'Invalid format';
    }
 
    if (rules.custom) {
      return rules.custom(value);
    }
 
    return null;
  }, [validationRules]);
 
  const setValue = useCallback((name: keyof T, value: T[keyof T]) => {
    setState(prev => {
      const newValues = { ...prev.values, [name]: value };
      const error = validateField(name, value);
      const newErrors = { ...prev.errors };
      
      if (error) {
        newErrors[name] = error;
      } else {
        delete newErrors[name];
      }
 
      const isValid = Object.keys(newErrors).length === 0;
 
      return {
        ...prev,
        values: newValues,
        errors: newErrors,
        isValid,
      };
    });
  }, [validateField]);
 
  const setTouched = useCallback((name: keyof T, touched: boolean = true) => {
    setState(prev => ({
      ...prev,
      touched: { ...prev.touched, [name]: touched },
    }));
  }, []);
 
  const validateAll = useCallback((): boolean => {
    const errors: Partial<Record<keyof T, string>> = {};
    
    Object.keys(initialValues).forEach(key => {
      const typedKey = key as keyof T;
      const error = validateField(typedKey, state.values[typedKey]);
      if (error) {
        errors[typedKey] = error;
      }
    });
 
    setState(prev => ({
      ...prev,
      errors,
      isValid: Object.keys(errors).length === 0,
    }));
 
    return Object.keys(errors).length === 0;
  }, [state.values, validateField, initialValues]);
 
  const handleSubmit = useCallback(async () => {
    if (!validateAll() || !onSubmit) return;
 
    setState(prev => ({ ...prev, isSubmitting: true }));
 
    try {
      await onSubmit(state.values);
    } catch (error) {
      console.error('Form submission error:', error);
    } finally {
      setState(prev => ({ ...prev, isSubmitting: false }));
    }
  }, [validateAll, onSubmit, state.values]);
 
  const reset = useCallback(() => {
    setState({
      values: initialValues,
      errors: {},
      touched: {},
      isSubmitting: false,
      isValid: true,
    });
  }, [initialValues]);
 
  return {
    values: state.values,
    errors: state.errors,
    touched: state.touched,
    isSubmitting: state.isSubmitting,
    isValid: state.isValid,
    setValue,
    setTouched,
    handleSubmit,
    reset,
    validateAll,
  };
}

Usage Examples

Async Data Fetching

// Example: Using useAsync for data fetching
import { useAsync } from '@/core/shared/hooks/useAsync';
import { productService } from '@/core/domains/products/services/productService';
 
function ProductList() {
  const { 
    data: products, 
    loading, 
    error, 
    execute: refetch 
  } = useAsync(
    () => productService.getProducts(),
    [], // dependencies
    {
      onSuccess: (data) => console.log('Products loaded:', data.length),
      onError: (error) => console.error('Failed to load products:', error),
    }
  );
 
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} onRetry={refetch} />;
 
  return (
    <FlatList
      data={products}
      renderItem={({ item }) => <ProductCard product={item} />}
      refreshing={loading}
      onRefresh={refetch}
    />
  );
}

Form with Validation

// Example: Using useForm for user registration
import { useForm } from '@/core/shared/hooks/useForm';
 
interface RegistrationForm {
  email: string;
  password: string;
  confirmPassword: string;
  name: string;
}
 
function RegistrationScreen() {
  const {
    values,
    errors,
    touched,
    isSubmitting,
    isValid,
    setValue,
    setTouched,
    handleSubmit,
  } = useForm<RegistrationForm>({
    initialValues: {
      email: '',
      password: '',
      confirmPassword: '',
      name: '',
    },
    validationRules: {
      email: {
        required: true,
        pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
      },
      password: {
        required: true,
        minLength: 8,
      },
      confirmPassword: {
        required: true,
        custom: (value) => {
          return values.password !== value ? 'Passwords do not match' : null;
        },
      },
      name: {
        required: true,
        minLength: 2,
      },
    },
    onSubmit: async (values) => {
      await authService.register(values);
    },
  });
 
  return (
    <View style={{ padding: 20 }}>
      <Input
        label="Name"
        value={values.name}
        onChangeText={(text) => setValue('name', text)}
        onBlur={() => setTouched('name')}
        error={touched.name ? errors.name : undefined}
      />
      
      <Input
        label="Email"
        value={values.email}
        onChangeText={(text) => setValue('email', text)}
        onBlur={() => setTouched('email')}
        error={touched.email ? errors.email : undefined}
        keyboardType="email-address"
      />
      
      <Input
        label="Password"
        value={values.password}
        onChangeText={(text) => setValue('password', text)}
        onBlur={() => setTouched('password')}
        error={touched.password ? errors.password : undefined}
        secureTextEntry
      />
      
      <Input
        label="Confirm Password"
        value={values.confirmPassword}
        onChangeText={(text) => setValue('confirmPassword', text)}
        onBlur={() => setTouched('confirmPassword')}
        error={touched.confirmPassword ? errors.confirmPassword : undefined}
        secureTextEntry
      />
      
      <Button
        onPress={handleSubmit}
        loading={isSubmitting}
        disabled={!isValid}
        style={{ marginTop: 20 }}
      >
        Register
      </Button>
    </View>
  );
}

Responsive Layout

// Example: Using device info for responsive design
import { useDeviceInfo } from '@/core/shared/hooks/useDeviceInfo';
 
function ResponsiveProductGrid() {
  const { width, isTablet, isLandscape } = useDeviceInfo();
 
  const numColumns = useMemo(() => {
    if (isTablet) {
      return isLandscape ? 4 : 3;
    }
    return isLandscape ? 3 : 2;
  }, [isTablet, isLandscape]);
 
  const itemWidth = (width - 40) / numColumns - 10;
 
  return (
    <FlatList
      data={products}
      numColumns={numColumns}
      key={numColumns} // Force re-render when columns change
      renderItem={({ item }) => (
        <ProductCard
          product={item}
          style={{ width: itemWidth }}
        />
      )}
      contentContainerStyle={{ padding: 20 }}
    />
  );
}

Testing Patterns

// __tests__/useAsync.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useAsync } from '../useAsync';
 
describe('useAsync', () => {
  it('executes async function immediately by default', async () => {
    const mockFn = jest.fn().mockResolvedValue('success');
    
    const { result, waitForNextUpdate } = renderHook(() =>
      useAsync(mockFn)
    );
 
    expect(result.current.loading).toBe(true);
    expect(mockFn).toHaveBeenCalled();
 
    await waitForNextUpdate();
 
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBe('success');
    expect(result.current.error).toBe(null);
  });
 
  it('handles errors correctly', async () => {
    const mockError = new Error('Test error');
    const mockFn = jest.fn().mockRejectedValue(mockError);
    
    const { result, waitForNextUpdate } = renderHook(() =>
      useAsync(mockFn)
    );
 
    await waitForNextUpdate();
 
    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(mockError);
  });
 
  it('allows manual execution', async () => {
    const mockFn = jest.fn().mockResolvedValue('success');
    
    const { result } = renderHook(() =>
      useAsync(mockFn, [], { immediate: false })
    );
 
    expect(result.current.loading).toBe(false);
    expect(mockFn).not.toHaveBeenCalled();
 
    await act(async () => {
      await result.current.execute();
    });
 
    expect(result.current.data).toBe('success');
    expect(mockFn).toHaveBeenCalled();
  });
});

Best Practices

Performance

  • ✅ Use proper dependency arrays to prevent unnecessary re-renders
  • ✅ Implement cleanup in useEffect to prevent memory leaks
  • ✅ Memoize expensive calculations with useMemo
  • ✅ Debounce user input to reduce API calls

Type Safety

  • ✅ Define generic types for reusable hooks
  • ✅ Use proper TypeScript constraints
  • ✅ Export type definitions alongside hooks
  • ✅ Provide default values with proper typing

Testing

  • ✅ Test hooks in isolation using @testing-library/react-hooks
  • ✅ Mock external dependencies properly
  • ✅ Test both success and error scenarios
  • ✅ Verify cleanup functions are called

Error Handling

  • ✅ Always handle async errors gracefully
  • ✅ Provide meaningful error messages
  • ✅ Implement retry mechanisms where appropriate
  • ✅ Log errors for debugging

Ready to use? Copy these hooks into your src/core/shared/hooks/ directory and import them throughout your application for consistent, reusable functionality.