React Hooks Deep Dive: Custom Hooks and Advanced Patterns
Explore advanced React Hooks patterns, create reusable custom hooks, and learn about hook composition, optimization, and common pitfalls to avoid.
React Hooks revolutionized how we write React components by allowing state and lifecycle logic in functional components. This deep dive explores advanced hook patterns, custom hooks, and optimization techniques.
Understanding Built-in Hooks
Before diving into custom hooks, let's review the built-in hooks and their advanced use cases.
Interactive Hook Example
Try modifying this counter component to see hooks in action!
import { useState, useEffect, useCallback, useMemo, useRef, useReducer } from 'react';// Advanced useState patternsfunction useToggle(initialValue = 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 };}// useReducer for complex state logicinterface State { count: number; loading: boolean; error: string | null;}type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'setLoading'; payload: boolean } | { type: 'setError'; payload: string | null };function counterReducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { ...state, count: state.count + 1, error: null }; case 'decrement': return { ...state, count: state.count - 1, error: null }; case 'setLoading': return { ...state, loading: action.payload }; case 'setError': return { ...state, error: action.payload, loading: false }; default: return state; }}function ComplexCounter() { const [state, dispatch] = useReducer(counterReducer, { count: 0, loading: false, error: null }); const incrementAsync = useCallback(async () => { dispatch({ type: 'setLoading', payload: true }); try { // Simulate API call await new Promise(resolve => setTimeout(resolve, 1000)); dispatch({ type: 'increment' }); } catch (error) { dispatch({ type: 'setError', payload: 'Failed to increment' }); } finally { dispatch({ type: 'setLoading', payload: false }); } }, []); return ( <div> <p>Count: {state.count}</p> {state.error && <p style={{ color: 'red' }}>{state.error}</p>} <button onClick={incrementAsync} disabled={state.loading}> {state.loading ? 'Loading...' : 'Increment'} </button> </div> );}
Custom Hook Patterns
import { useState, useEffect } from 'react';export 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) { return initialValue; } }); const setValue = (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 saving to localStorage:', error); } }; return [storedValue, setValue] as const;}
import { useState, useEffect, useCallback } from 'react';interface FetchState<T> { data: T | null; loading: boolean; error: string | null;}interface FetchOptions { immediate?: boolean; dependencies?: any[];}export function useFetch<T>( url: string, options: FetchOptions = {}): FetchState<T> & { refetch: () => Promise<void> } { const { immediate = true, dependencies = [] } = options; const [state, setState] = useState<FetchState<T>>({ data: null, loading: false, error: null }); const fetchData = useCallback(async () => { setState(prev => ({ ...prev, loading: true, error: null })); try { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setState({ data, loading: false, error: null }); } catch (error) { setState({ data: null, loading: false, error: error instanceof Error ? error.message : 'An error occurred' }); } }, [url]); useEffect(() => { if (immediate) { fetchData(); } }, [fetchData, immediate, ...dependencies]); return { ...state, refetch: fetchData };}
Performance Optimization with Hooks
import { useState, useMemo, useCallback, memo } from 'react';interface User { id: number; name: string; email: string; department: string;}// Expensive computation examplefunction useFilteredUsers(users: User[], searchTerm: string, department: string) { return useMemo(() => { return users.filter(user => { const matchesSearch = user.name.toLowerCase().includes(searchTerm.toLowerCase()) || user.email.toLowerCase().includes(searchTerm.toLowerCase()); const matchesDepartment = department === 'all' || user.department === department; return matchesSearch && matchesDepartment; }); }, [users, searchTerm, department]);}// Memoized component to prevent unnecessary re-rendersconst UserCard = memo(({ user, onDelete }: { user: User; onDelete: (id: number) => void }) => { console.log('UserCard rendered for', user.name); return ( <div className="user-card"> <h3>{user.name}</h3> <p>{user.email}</p> <p>{user.department}</p> <button onClick={() => onDelete(user.id)}>Delete</button> </div> );});function UserList({ users }: { users: User[] }) { const [searchTerm, setSearchTerm] = useState(''); const [selectedDepartment, setSelectedDepartment] = useState('all'); // Memoize filtered users to avoid recalculation on every render const filteredUsers = useFilteredUsers(users, searchTerm, selectedDepartment); // Memoize callback to prevent child re-renders const handleDelete = useCallback((id: number) => { // Delete logic here console.log('Delete user', id); }, []); return ( <div> <input type="text" placeholder="Search users..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} /> <select value={selectedDepartment} onChange={e => setSelectedDepartment(e.target.value)} > <option value="all">All Departments</option> <option value="engineering">Engineering</option> <option value="marketing">Marketing</option> <option value="sales">Sales</option> </select> <div className="user-grid"> {filteredUsers.map(user => ( <UserCard key={user.id} user={user} onDelete={handleDelete} /> ))} </div> </div> );}
Advanced Custom Hook Patterns
import { useState, useCallback, useRef, useEffect } from 'react';interface AsyncState<T, E = Error> { data: T | null; error: E | null; loading: boolean;}export function useAsyncState<T, E = Error>( asyncFunction: () => Promise<T>): [AsyncState<T, E>, () => Promise<void>, () => void] { const [state, setState] = useState<AsyncState<T, E>>({ data: null, error: null, loading: false }); const cancelRef = useRef<boolean>(false); const execute = useCallback(async () => { setState({ data: null, error: null, loading: true }); cancelRef.current = false; try { const data = await asyncFunction(); if (!cancelRef.current) { setState({ data, error: null, loading: false }); } } catch (error) { if (!cancelRef.current) { setState({ data: null, error: error as E, loading: false }); } } }, [asyncFunction]); const cancel = useCallback(() => { cancelRef.current = true; setState(prev => ({ ...prev, loading: false })); }, []); useEffect(() => { return () => { cancelRef.current = true; }; }, []); return [state, execute, cancel];}
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;}// Usage examplefunction SearchComponent() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 300); useEffect(() => { if (debouncedSearchTerm) { // Perform search API call console.log('Searching for:', debouncedSearchTerm); } }, [debouncedSearchTerm]); return ( <input type="text" placeholder="Search..." value={searchTerm} onChange={e => setSearchTerm(e.target.value)} /> );}
Hook Composition and Patterns
import { useState, useCallback, useMemo } from 'react';interface ValidationRule<T> { message: string; validator: (value: T) => boolean;}interface UseFormValidationOptions<T> { initialValues: T; validationRules: Partial<Record<keyof T, ValidationRule<T[keyof T]>[]>>; onSubmit: (values: T) => void | Promise<void>;}export function useFormValidation<T extends Record<string, any>>({ initialValues, validationRules, onSubmit}: UseFormValidationOptions<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 validateField = useCallback((name: keyof T, value: T[keyof T]) => { const rules = validationRules[name]; if (!rules) return ''; for (const rule of rules) { if (!rule.validator(value)) { return rule.message; } } return ''; }, [validationRules]); const handleChange = useCallback((name: keyof T) => ( event: React.ChangeEvent<HTMLInputElement> ) => { const value = event.target.value as T[keyof T]; setValues(prev => ({ ...prev, [name]: value })); if (touched[name]) { const error = validateField(name, value); setErrors(prev => ({ ...prev, [name]: error })); } }, [touched, validateField]); const handleBlur = useCallback((name: keyof T) => () => { setTouched(prev => ({ ...prev, [name]: true })); const error = validateField(name, values[name]); setErrors(prev => ({ ...prev, [name]: error })); }, [values, validateField]); const isValid = useMemo(() => { return Object.keys(validationRules).every(key => { const fieldName = key as keyof T; return !validateField(fieldName, values[fieldName]); }); }, [values, validationRules, validateField]); const handleSubmit = useCallback(async (event: React.FormEvent) => { event.preventDefault(); // Mark all fields as touched const allTouched = Object.keys(initialValues).reduce((acc, key) => ({ ...acc, [key]: true }), {} as Record<keyof T, boolean>); setTouched(allTouched); // Validate all fields const allErrors = Object.keys(validationRules).reduce((acc, key) => { const fieldName = key as keyof T; const error = validateField(fieldName, values[fieldName]); return { ...acc, [fieldName]: error }; }, {} as Record<keyof T, string>); setErrors(allErrors); if (isValid) { setIsSubmitting(true); try { await onSubmit(values); } catch (error) { console.error('Form submission error:', error); } finally { setIsSubmitting(false); } } }, [values, isValid, onSubmit, initialValues, validationRules, validateField]); return { values, errors, touched, isSubmitting, isValid, handleChange, handleBlur, handleSubmit, setValues, setErrors };}
Testing Custom Hooks
import { renderHook, act } from '@testing-library/react';import { useLocalStorage } from './useLocalStorage';// Mock localStorageconst localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(),};Object.defineProperty(window, 'localStorage', { value: localStorageMock});describe('useLocalStorage', () => { beforeEach(() => { localStorageMock.getItem.mockClear(); localStorageMock.setItem.mockClear(); }); it('should return initial value when localStorage is empty', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('test-key', 'default-value')); expect(result.current[0]).toBe('default-value'); }); it('should return stored value from localStorage', () => { localStorageMock.getItem.mockReturnValue(JSON.stringify('stored-value')); const { result } = renderHook(() => useLocalStorage('test-key', 'default-value')); expect(result.current[0]).toBe('stored-value'); }); it('should update localStorage when value changes', () => { localStorageMock.getItem.mockReturnValue(null); const { result } = renderHook(() => useLocalStorage('test-key', 'default-value')); act(() => { result.current[1]('new-value'); }); expect(localStorageMock.setItem).toHaveBeenCalledWith( 'test-key', JSON.stringify('new-value') ); expect(result.current[0]).toBe('new-value'); }); it('should handle function updates', () => { localStorageMock.getItem.mockReturnValue(JSON.stringify('initial')); const { result } = renderHook(() => useLocalStorage('test-key', 'default')); act(() => { result.current[1]((prev: string) => prev + '-updated'); }); expect(result.current[0]).toBe('initial-updated'); });});
Reusable stateful logic that encapsulates complex behavior.
Combining multiple hooks to create powerful abstractions.
Optimization with useMemo, useCallback, and React.memo.
Comprehensive testing strategies for custom hooks.
“Custom hooks are where the real power of React Hooks shines. They allow you to extract component logic into reusable functions that can be shared across your entire application.”