React Custom Hooks Guide - 2025 Best Practices
Custom hooks are the key to reusable logic in React. This guide covers essential patterns for building maintainable and testable custom hooks.
1. API Data Fetching Hook
// hooks/useApi.ts
import { useState, useEffect, useCallback } from 'react';
interface UseApiOptions {
immediate?: boolean;
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
}
function useApi<T>(url: string, options: UseApiOptions = {}) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const execute = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
options.onSuccess?.(result);
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error');
setError(error);
options.onError?.(error);
} finally {
setLoading(false);
}
}, [url, options.onSuccess, options.onError]);
useEffect(() => {
if (options.immediate !== false) {
execute();
}
}, [execute, options.immediate]);
const refetch = useCallback(() => {
execute();
}, [execute]);
return { data, loading, error, refetch };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, refetch } = useApi<User>(
`/api/users/${userId}`,
{
onSuccess: (user) => console.log('User loaded:', user.name),
onError: (error) => console.error('Failed to load user:', error)
}
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user?.name}</h1>
<button onClick={refetch}>Refresh</button>
</div>
);
}
2. Local Storage Hook
// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
const removeValue = useCallback(() => {
try {
window.localStorage.removeItem(key);
setStoredValue(initialValue);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
return [storedValue, setValue, removeValue] as const;
}
// Usage
function Settings() {
const [theme, setTheme, removeTheme] = useLocalStorage('theme', 'light');
const [language, setLanguage] = useLocalStorage('language', 'en');
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button onClick={removeTheme}>Reset Theme</button>
</div>
);
}
3. Form Handling Hook
// hooks/useForm.ts
import { useState, useCallback } from 'react';
interface UseFormOptions<T> {
initialValues: T;
validate?: (values: T) => Partial<Record<keyof T, string>>;
onSubmit: (values: T) => Promise<void> | void;
}
function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback((name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: undefined }));
}
}, [errors]);
const handleBlur = useCallback((name: keyof T) => {
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) {
const fieldErrors = validate(values);
if (fieldErrors[name]) {
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }));
}
}
}, [values, validate]);
const handleSubmit = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault();
if (validate) {
const formErrors = validate(values);
setErrors(formErrors);
if (Object.keys(formErrors).length > 0) {
return;
}
}
setIsSubmitting(true);
try {
await onSubmit(values);
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
}, [values, validate, onSubmit]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset
};
}
// Usage
interface LoginForm {
email: string;
password: string;
}
function LoginPage() {
const form = useForm<LoginForm>({
initialValues: { email: '', password: '' },
validate: (values) => {
const errors: Partial<Record<keyof LoginForm, string>> = {};
if (!values.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Email is invalid';
}
if (!values.password) {
errors.password = 'Password is required';
} else if (values.password.length < 6) {
errors.password = 'Password must be at least 6 characters';
}
return errors;
},
onSubmit: async (values) => {
await login(values);
}
});
return (
<form onSubmit={form.handleSubmit}>
<input
type="email"
value={form.values.email}
onChange={(e) => form.handleChange('email', e.target.value)}
onBlur={() => form.handleBlur('email')}
/>
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
<input
type="password"
value={form.values.password}
onChange={(e) => form.handleChange('password', e.target.value)}
onBlur={() => form.handleBlur('password')}
/>
{form.touched.password && form.errors.password && (
<span>{form.errors.password}</span>
)}
<button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
4. Debounced Value Hook
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
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;
}
// Usage with search
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const [results, setResults] = useState([]);
useEffect(() => {
if (debouncedSearchTerm) {
searchApi(debouncedSearchTerm).then(setResults);
} else {
setResults([]);
}
}, [debouncedSearchTerm]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.map(result => (
<li key={result.id}>{result.name}</li>
))}
</ul>
</div>
);
}
5. Intersection Observer Hook
// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from 'react';
interface UseIntersectionObserverOptions {
threshold?: number;
rootMargin?: string;
triggerOnce?: boolean;
}
function useIntersectionObserver({
threshold = 0.1,
rootMargin = '0px',
triggerOnce = false
}: UseIntersectionObserverOptions = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const targetRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsIntersecting(entry.isIntersecting);
if (triggerOnce && entry.isIntersecting) {
observer.disconnect();
}
},
{ threshold, rootMargin }
);
const currentTarget = targetRef.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [threshold, rootMargin, triggerOnce]);
return [targetRef, isIntersecting] as const;
}
// Usage for lazy loading
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [ref, isIntersecting] = useIntersectionObserver({
triggerOnce: true,
threshold: 0.1
});
return (
<div ref={ref}>
{isIntersecting ? (
<img src={src} alt={alt} />
) : (
<div className="placeholder">Loading...</div>
)}
</div>
);
}
6. Previous Value Hook
// hooks/usePrevious.ts
import { useRef, useEffect } from 'react';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const previousUserId = usePrevious(userId);
useEffect(() => {
if (userId !== previousUserId) {
console.log(`User changed from ${previousUserId} to ${userId}`);
fetchUser(userId).then(setUser);
}
}, [userId, previousUserId]);
return <div>{user?.name}</div>;
}
7. Toggle Hook
// hooks/useToggle.ts
import { useState, useCallback } from 'react';
function useToggle(initialValue: boolean = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(prev => !prev);
}, []);
const setTrue = useCallback(() => {
setValue(true);
}, []);
const setFalse = useCallback(() => {
setValue(false);
}, []);
return [value, { toggle, setTrue, setFalse }] as const;
}
// Usage
function Modal() {
const [isOpen, { toggle, setTrue, setFalse }] = useToggle();
return (
<div>
<button onClick={setTrue}>Open Modal</button>
{isOpen && (
<div className="modal">
<h2>Modal Content</h2>
<button onClick={setFalse}>Close</button>
<button onClick={toggle}>Toggle</button>
</div>
)}
</div>
);
}
8. Custom Hook Testing
// __tests__/useApi.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useApi } from '../hooks/useApi';
// Mock fetch
global.fetch = jest.fn();
describe('useApi', () => {
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
test('should fetch data successfully', async () => {
const mockData = { id: 1, name: 'John' };
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useApi('/api/users/1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBe(null);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual(mockData);
expect(result.current.error).toBe(null);
});
test('should handle fetch error', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const { result } = renderHook(() => useApi('/api/users/1'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toBe(null);
expect(result.current.error?.message).toBe('Network error');
});
});
Best Practices
Hook Composition
// Combine multiple hooks for complex functionality
function useUserManagement(userId: string) {
const { data: user, loading, error, refetch } = useApi(`/api/users/${userId}`);
const [userPreferences, setUserPreferences] = useLocalStorage('userPrefs', {});
const previousUserId = usePrevious(userId);
useEffect(() => {
if (userId !== previousUserId && user) {
// Load user preferences when user changes
setUserPreferences(user.preferences || {});
}
}, [userId, previousUserId, user, setUserPreferences]);
return {
user,
loading,
error,
userPreferences,
setUserPreferences,
refetchUser: refetch
};
}
Dependency Management
// Always include all dependencies in useCallback/useEffect
function useApiWithParams(baseUrl: string, params: Record<string, any>) {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const url = new URL(baseUrl);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
const response = await fetch(url.toString());
const result = await response.json();
setData(result);
}, [baseUrl, params]); // Include all dependencies
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, refetch: fetchData };
}