Custom Hooks
Learn how to build your own hooks to extract and reuse stateful logic across components, making your code more modular and maintainable.
Learning Objectives
- Understand what custom hooks are and why they're useful
- Learn the rules and conventions for building custom hooks
- Extract stateful logic from components into reusable hooks
- Build common custom hook patterns (fetch, localStorage, etc.)
- Compose hooks together for complex functionality
- Test and debug custom hooks effectively
Custom Hooks
Imagine you're a chef who's discovered an amazing recipe for a special sauce. You could keep making it from scratch every time, or you could write down the recipe and teach it to other chefs so they can use it too. Custom hooks are like those reusable recipes for React logic!
Custom hooks let you extract stateful logic from components and reuse it anywhere. They're one of React's most powerful features for building clean, maintainable applications.
The Problem: Repeated Logic
Let's start by seeing a common problem. Imagine you're building an app where multiple components need to fetch data from an API:
// UserProfile component function UserProfile({ userId }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchUser = async () => { try { setLoading(true); const response = await fetch('/api/users/' + userId); const userData = await response.json(); setUser(userData); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchUser(); }, [userId]); if (loading) return <div>Loading user...</div>; if (error) return <div>Error: {error}</div>; return <div>Welcome, {user?.name}!</div>; } // PostsList component function PostsList() { const [posts, setPosts] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchPosts = async () => { try { setLoading(true); const response = await fetch('/api/posts'); const postsData = await response.json(); setPosts(postsData); } catch (err) { setError(err.message); } finally { setLoading(false); } }; fetchPosts(); }, []); if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error}</div>; return ( <div> {posts?.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> ); }
See the problem? Both components have almost identical logic for:
- Managing loading, data, and error states
- Making API calls in useEffect
- Handling try/catch logic
- Rendering loading and error states
This is code duplication at its worst! Every time you need to fetch data, you copy-paste the same 20 lines of code.
What Are Custom Hooks?
Custom hooks are JavaScript functions that:
- Start with the word "use" (like
useMyCustomHook
) - Can call other hooks inside them
- Let you share stateful logic between components
- Return whatever you want (values, functions, objects)
Think of them as recipes for stateful behavior that you can use in any component.
Your First Custom Hook
Let's extract the data fetching logic into a custom hook:
// Custom hook for data fetching function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); setError(null); const response = await fetch(url); if (!response.ok) { throw new Error('HTTP error! status: ' + response.status); } const result = await response.json(); setData(result); } catch (err) { setError(err.message); } finally { setLoading(false); } }; if (url) { fetchData(); } }, [url]); return { data, loading, error }; } // Now our components become much simpler! function UserProfile({ userId }) { const { data: user, loading, error } = useFetch('/api/users/' + userId); if (loading) return <div>Loading user...</div>; if (error) return <div>Error: {error}</div>; return <div>Welcome, {user?.name}!</div>; } function PostsList() { const { data: posts, loading, error } = useFetch('/api/posts'); if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error}</div>; return ( <div> {posts?.map(post => ( <div key={post.id}>{post.title}</div> ))} </div> ); }
Amazing! Look how much cleaner our components became:
- ✅ No more duplicate code - fetch logic is centralized
- ✅ Easier to test - you can test the hook separately
- ✅ Consistent behavior - all components handle loading/error the same way
- ✅ Easier to maintain - fix bugs in one place
Rules for Custom Hooks
Rule 1: Always Start with "use"
// ✅ Good - follows naming convention function useCounter() { ... } function useLocalStorage() { ... } function useFetch() { ... } // ❌ Bad - doesn't start with "use" function counter() { ... } function getFromStorage() { ... }
Why? This tells React (and other developers) that it's a hook and follows hook rules.
Rule 2: Only Call Hooks at the Top Level
// ✅ Good function useMyHook() { const [state, setState] = useState(0); useEffect(() => { ... }, []); return state; } // ❌ Bad function useMyHook(condition) { if (condition) { const [state, setState] = useState(0); // Don't do this! } }
Rule 3: Custom Hooks Can Call Other Hooks
function useUserProfile(userId) { // This hook can use other hooks! const { data, loading, error } = useFetch('/api/users/' + userId); const [favorites, setFavorites] = useLocalStorage('userFavorites', []); return { user: data, loading, error, favorites, setFavorites }; }
Building Useful Custom Hooks
1. useLocalStorage Hook
Let's build a hook that syncs state with localStorage:
function useLocalStorage(key, initialValue) { // Get value from localStorage or use initial value const [storedValue, setStoredValue] = useState(() => { 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; } }); // Return a wrapped version of useState's setter function that // persists the new value to localStorage const setValue = (value) => { try { // Allow value to be a function so we have the same API as useState 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); } }; return [storedValue, setValue]; } // Usage in components function UserPreferences() { const [theme, setTheme] = useLocalStorage('theme', 'light'); const [language, setLanguage] = useLocalStorage('language', 'en'); return ( <div> <h2>User Preferences</h2> <div> <label>Theme: </label> <select value={theme} onChange={(e) => setTheme(e.target.value)}> <option value="light">Light</option> <option value="dark">Dark</option> </select> </div> <div> <label>Language: </label> <select value={language} onChange={(e) => setLanguage(e.target.value)}> <option value="en">English</option> <option value="es">Spanish</option> <option value="fr">French</option> </select> </div> <p>Your preferences are automatically saved!</p> </div> ); }
2. useToggle Hook
A simple but useful hook for boolean states:
function 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, setValue }]; } // Usage function ToggleDemo() { const [isVisible, { toggle, setTrue, setFalse }] = useToggle(false); const [isEnabled, { toggle: toggleEnabled }] = useToggle(true); return ( <div> <h2>Toggle Demo</h2> <div> <button onClick={toggle}> {isVisible ? 'Hide' : 'Show'} Content </button> <button onClick={setTrue}>Show</button> <button onClick={setFalse}>Hide</button> </div> {isVisible && ( <div style={{ padding: '20px', backgroundColor: '#e3f2fd' }}> <p>This content can be toggled!</p> </div> )} <div> <label> <input type="checkbox" checked={isEnabled} onChange={toggleEnabled} /> Feature enabled </label> </div> </div> ); }
3. useDebounce Hook
Perfect for search inputs and API calls:
function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); // Cleanup timeout if value changes before delay return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } // Usage in a search component function SearchResults() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); const { data: results, loading } = useFetch( debouncedSearchTerm ? '/api/search?q=' + debouncedSearchTerm : null ); return ( <div> <h2>Search</h2> <input type="text" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} placeholder="Type to search..." style={{ padding: '8px', width: '300px' }} /> <div style={{ marginTop: '20px' }}> {loading && <p>Searching...</p>} {results && results.length > 0 && ( <ul> {results.map(result => ( <li key={result.id}>{result.title}</li> ))} </ul> )} {results && results.length === 0 && !loading && ( <p>No results found for "{debouncedSearchTerm}"</p> )} </div> </div> ); }
4. useCounter Hook
A more sophisticated counter with multiple operations:
function useCounter(initialValue = 0, { min, max } = {}) { const [count, setCount] = useState(initialValue); const increment = useCallback((step = 1) => { setCount(prev => { const newValue = prev + step; if (max !== undefined && newValue > max) return max; return newValue; }); }, [max]); const decrement = useCallback((step = 1) => { setCount(prev => { const newValue = prev - step; if (min !== undefined && newValue < min) return min; return newValue; }); }, [min]); const reset = useCallback(() => { setCount(initialValue); }, [initialValue]); const set = useCallback((value) => { setCount(value); }, []); return { count, increment, decrement, reset, set, isAtMin: min !== undefined && count <= min, isAtMax: max !== undefined && count >= max }; } // Usage function CounterDemo() { const { count, increment, decrement, reset, isAtMin, isAtMax } = useCounter(5, { min: 0, max: 10 }); return ( <div style={{ textAlign: 'center', padding: '20px' }}> <h2>Smart Counter</h2> <div style={{ fontSize: '48px', margin: '20px 0' }}> {count} </div> <div> <button onClick={() => decrement()} disabled={isAtMin} style={{ margin: '0 10px' }} > -1 </button> <button onClick={() => decrement(5)} disabled={isAtMin} style={{ margin: '0 10px' }} > -5 </button> <button onClick={reset} style={{ margin: '0 10px' }} > Reset </button> <button onClick={() => increment(5)} disabled={isAtMax} style={{ margin: '0 10px' }} > +5 </button> <button onClick={() => increment()} disabled={isAtMax} style={{ margin: '0 10px' }} > +1 </button> </div> <p style={{ marginTop: '20px', color: '#666' }}> Range: 0 to 10 {isAtMin && ' (At minimum!)'} {isAtMax && ' (At maximum!)'} </p> </div> ); }
Advanced Custom Hook: useApi
Let's build a more sophisticated API hook with caching and refetching:
function useApi(url, options = {}) { const { immediate = true, dependencies = [], transform = (data) => data } = options; const [state, setState] = useState({ data: null, loading: immediate, error: null, lastFetched: null }); const fetchData = useCallback(async (overrideUrl) => { const fetchUrl = overrideUrl || url; if (!fetchUrl) return; setState(prev => ({ ...prev, loading: true, error: null })); try { const response = await fetch(fetchUrl); if (!response.ok) { throw new Error('HTTP ' + response.status + ': ' + response.statusText); } const rawData = await response.json(); const transformedData = transform(rawData); setState({ data: transformedData, loading: false, error: null, lastFetched: new Date() }); return transformedData; } catch (error) { setState({ data: null, loading: false, error: error.message, lastFetched: null }); throw error; } }, [url, transform]); const refetch = useCallback(() => fetchData(), [fetchData]); const mutate = useCallback((newData) => { setState(prev => ({ ...prev, data: newData, lastFetched: new Date() })); }, []); useEffect(() => { if (immediate && url) { fetchData(); } }, [immediate, ...dependencies]); return { ...state, refetch, mutate, fetch: fetchData }; } // Usage in a component function UserDashboard({ userId }) { const { data: user, loading: userLoading, error: userError, refetch: refetchUser } = useApi('/api/users/' + userId, { dependencies: [userId], transform: (userData) => ({ ...userData, fullName: userData.firstName + ' ' + userData.lastName }) }); const { data: posts, loading: postsLoading, error: postsError, fetch: fetchPosts } = useApi(null, { immediate: false }); const loadUserPosts = () => { if (user) { fetchPosts('/api/users/' + user.id + '/posts'); } }; if (userLoading) return <div>Loading user...</div>; if (userError) return <div>Error: {userError}</div>; return ( <div style={{ padding: '20px' }}> <h1>Welcome, {user?.fullName}!</h1> <p>Email: {user?.email}</p> <button onClick={refetchUser} style={{ marginRight: '10px' }}> Refresh User Data </button> <button onClick={loadUserPosts}> Load My Posts </button> {postsLoading && <p>Loading posts...</p>} {postsError && <p>Error loading posts: {postsError}</p>} {posts && ( <div style={{ marginTop: '20px' }}> <h3>Your Posts ({posts.length})</h3> {posts.map(post => ( <div key={post.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0', borderRadius: '5px' }}> <h4>{post.title}</h4> <p>{post.excerpt}</p> </div> ))} </div> )} </div> ); }
Composing Hooks Together
The real power of custom hooks comes from combining them:
// Combining multiple hooks for a complex feature function useShoppingCart() { const [cart, setCart] = useLocalStorage('shoppingCart', []); const [isOpen, { toggle: toggleCart }] = useToggle(false); const addItem = useCallback((product) => { setCart(prev => { const existingItem = prev.find(item => item.id === product.id); if (existingItem) { return prev.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); } return [...prev, { ...product, quantity: 1 }]; }); }, [setCart]); const removeItem = useCallback((productId) => { setCart(prev => prev.filter(item => item.id !== productId)); }, [setCart]); const updateQuantity = useCallback((productId, quantity) => { if (quantity <= 0) { removeItem(productId); return; } setCart(prev => prev.map(item => item.id === productId ? { ...item, quantity } : item ) ); }, [setCart, removeItem]); const clearCart = useCallback(() => { setCart([]); }, [setCart]); const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); const totalPrice = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); return { cart, isOpen, toggleCart, addItem, removeItem, updateQuantity, clearCart, totalItems, totalPrice }; } // Using the composed hook function ShoppingApp() { const { cart, isOpen, toggleCart, addItem, removeItem, totalItems, totalPrice } = useShoppingCart(); const sampleProducts = [ { id: 1, name: 'T-Shirt', price: 19.99 }, { id: 2, name: 'Jeans', price: 49.99 }, { id: 3, name: 'Sneakers', price: 89.99 } ]; return ( <div style={{ padding: '20px' }}> <header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <h1>My Store</h1> <button onClick={toggleCart}> 🛒 Cart ({totalItems}) - $" + totalPrice.toFixed(2) + " </button> </header> {isOpen && ( <div style={{ position: 'fixed', top: 0, right: 0, width: '300px', height: '100vh', backgroundColor: 'white', boxShadow: '-2px 0 10px rgba(0,0,0,0.1)', padding: '20px', zIndex: 1000 }}> <h2>Shopping Cart</h2> <button onClick={toggleCart} style={{ float: 'right' }}>×</button> {cart.length === 0 ? ( <p>Your cart is empty</p> ) : ( <> {cart.map(item => ( <div key={item.id} style={{ border: '1px solid #ddd', padding: '10px', margin: '10px 0' }}> <h4>{item.name}</h4> <p>$" + item.price + " x {item.quantity}</p> <button onClick={() => removeItem(item.id)}> Remove </button> </div> ))} <div style={{ marginTop: '20px', fontWeight: 'bold' }}> Total: $" + totalPrice.toFixed(2) + " </div> </> )} </div> )} <div> <h2>Products</h2> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '20px' }}> {sampleProducts.map(product => ( <div key={product.id} style={{ border: '1px solid #ddd', padding: '15px', textAlign: 'center' }}> <h3>{product.name}</h3> <p>$" + product.price + "</p> <button onClick={() => addItem(product)}> Add to Cart </button> </div> ))} </div> </div> </div> ); }
Practice Exercise: Build a useFormValidation Hook
Try building a custom hook for form validation:
Requirements:
- Handle form values, errors, and touched states
- Support custom validation rules
- Provide helper functions for form submission
- Show errors only after fields are touched
Starter structure:
function useFormValidation(initialValues, validationRules) { // Your hook implementation here return { values, errors, touched, isValid, handleChange, handleBlur, handleSubmit, reset }; } // Usage function ContactForm() { const { values, errors, touched, isValid, handleChange, handleBlur, handleSubmit, reset } = useFormValidation( { name: '', email: '', message: '' }, { name: (value) => value.length < 2 ? 'Name must be at least 2 characters' : null, email: (value) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email' : null, message: (value) => value.length < 10 ? 'Message must be at least 10 characters' : null } ); const onSubmit = (formData) => { console.log('Form submitted:', formData); alert('Form submitted successfully!'); reset(); }; return ( <form onSubmit={handleSubmit(onSubmit)}> {/* Your form fields here */} </form> ); }
Testing Custom Hooks
Custom hooks can be tested independently using React Testing Library:
import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; describe('useCounter', () => { test('should initialize with default value', () => { const { result } = renderHook(() => useCounter()); expect(result.current.count).toBe(0); }); test('should initialize with custom value', () => { const { result } = renderHook(() => useCounter(10)); expect(result.current.count).toBe(10); }); test('should increment count', () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); }); test('should respect max limit', () => { const { result } = renderHook(() => useCounter(9, { max: 10 })); act(() => { result.current.increment(5); }); expect(result.current.count).toBe(10); expect(result.current.isAtMax).toBe(true); }); });
Common Custom Hook Patterns
1. Data Fetching Pattern
function useData(url, options) { // Fetch data with loading, error states // Support caching, retries, etc. }
2. State Management Pattern
function useFormState(initialState) { // Manage form state with validation // Handle field updates, errors, submission }
3. Side Effects Pattern
function useDocumentTitle(title) { // Update document title // Clean up on unmount }
4. Event Handling Pattern
function useKeyPress(targetKey, handler) { // Listen for specific key presses // Clean up event listeners }
5. Storage Pattern
function usePersistentState(key, defaultValue) { // Sync state with localStorage/sessionStorage // Handle serialization/deserialization }
Best Practices for Custom Hooks
1. Keep Hooks Focused
// ✅ Good - single responsibility function useLocalStorage(key, defaultValue) { ... } function useDebounce(value, delay) { ... } // ❌ Bad - too many responsibilities function useEverything() { // Handles API calls, localStorage, validation, etc. }
2. Return Objects for Complex State
// ✅ Good - clear and extensible function useCounter() { return { count, increment, decrement, reset, isAtMax, isAtMin }; } // ❌ Bad - hard to remember order function useCounter() { return [count, increment, decrement, reset, isAtMax, isAtMin]; }
3. Use useCallback for Functions
function useApi(url) { const fetchData = useCallback(async () => { // Fetch logic }, [url]); return { data, loading, error, refetch: fetchData }; }
4. Handle Edge Cases
function useLocalStorage(key, defaultValue) { const [value, setValue] = useState(() => { try { // Handle SSR, localStorage not available, etc. if (typeof window === 'undefined') return defaultValue; const item = localStorage.getItem(key); return item ? JSON.parse(item) : defaultValue; } catch { return defaultValue; } }); // ... rest of hook }
What We've Learned
Congratulations! You now understand:
✅ What custom hooks are and why they're powerful
✅ How to extract reusable logic from components
✅ Common patterns for data fetching, state management, and side effects
✅ How to compose hooks together for complex functionality
✅ Best practices for building maintainable custom hooks
✅ Testing strategies for custom hooks
Quick Recap Quiz
Test your custom hooks knowledge:
- What makes a function a "custom hook"?
- Can custom hooks call other hooks?
- What should you return from a custom hook with complex state?
- Why is the "use" prefix important?
Answers: 1) Starts with "use" and can call other hooks, 2) Yes, that's their main power, 3) An object with named properties, 4) It tells React to enforce hook rules
What's Next?
In our next lesson, we'll learn about React Router - how to add navigation and multiple pages to your React applications. You'll discover how to create SPAs (Single Page Applications) with client-side routing, handle URL parameters, and build complex navigation patterns.
Custom hooks will be incredibly useful for managing router-related state and logic!