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.