Javascript
Front End
React.js
hooks
code-reusability
One of the most powerful features of React's hooks API is the ability to create your own custom hooks. Custom hooks allow you to extract component logic into reusable functions, making your code more modular, maintainable, and testable.
Custom hooks are JavaScript functions that start with the name "use" and may call other hooks. They enable you to extract and reuse stateful logic from components without changing your component hierarchy.
Key characteristics of custom hooks:
useFormValidation
, useLocalStorage
)Custom hooks solve several common problems in React development:
Let's start with a simple example. Here's a custom hook that manages form input state:
1import { useState } from 'react'; 2 3function useInput(initialValue = '') { 4 const [value, setValue] = useState(initialValue); 5 6 const handleChange = (event) => { 7 setValue(event.target.value); 8 }; 9 10 const reset = () => { 11 setValue(initialValue); 12 }; 13 14 return { 15 value, 16 onChange: handleChange, 17 reset 18 }; 19}
Now we can use this hook in our components:
1function SimpleForm() { 2 const nameInput = useInput(''); 3 const emailInput = useInput(''); 4 5 const handleSubmit = (event) => { 6 event.preventDefault(); 7 console.log('Submitted:', nameInput.value, emailInput.value); 8 9 // Reset the form 10 nameInput.reset(); 11 emailInput.reset(); 12 }; 13 14 return ( 15 <form onSubmit={handleSubmit}> 16 <div> 17 <label>Name:</label> 18 <input 19 type="text" 20 value={nameInput.value} 21 onChange={nameInput.onChange} 22 /> 23 </div> 24 25 <div> 26 <label>Email:</label> 27 <input 28 type="email" 29 value={emailInput.value} 30 onChange={emailInput.onChange} 31 /> 32 </div> 33 34 <button type="submit">Submit</button> 35 </form> 36 ); 37}
Note how the custom hook:
value
, onChange
, reset
)Fetching data is a common task in React applications. Here's a custom hook that handles loading states, errors, and data:
1import { useState, useEffect } from 'react'; 2 3function useFetch(url) { 4 const [data, setData] = useState(null); 5 const [loading, setLoading] = useState(true); 6 const [error, setError] = useState(null); 7 8 useEffect(() => { 9 // Reset states when URL changes 10 setLoading(true); 11 setData(null); 12 setError(null); 13 14 async function fetchData() { 15 try { 16 const response = await fetch(url); 17 18 if (!response.ok) { 19 throw new Error(`HTTP error! Status: ${response.status}`); 20 } 21 22 const result = await response.json(); 23 setData(result); 24 } catch (err) { 25 setError(err.message); 26 } finally { 27 setLoading(false); 28 } 29 } 30 31 fetchData(); 32 }, [url]); 33 34 return { data, loading, error }; 35}
Using the fetch hook:
1function UserProfile({ userId }) { 2 const { data, loading, error } = useFetch(`/api/users/${userId}`); 3 4 if (loading) return <p>Loading...</p>; 5 if (error) return <p>Error: {error}</p>; 6 7 return ( 8 <div> 9 <h2>{data.name}</h2> 10 <p>Email: {data.email}</p> 11 <p>Role: {data.role}</p> 12 </div> 13 ); 14}
Managing data in local storage is another common use case:
1import { useState, useEffect } from 'react'; 2 3function useLocalStorage(key, initialValue) { 4 // State to store our value 5 const [storedValue, setStoredValue] = useState(() => { 6 try { 7 // Get from local storage by key 8 const item = window.localStorage.getItem(key); 9 // Parse stored json or return initialValue 10 return item ? JSON.parse(item) : initialValue; 11 } catch (error) { 12 // If error, return initialValue 13 console.error(error); 14 return initialValue; 15 } 16 }); 17 18 // Return a wrapped version of useState's setter function that 19 // persists the new value to localStorage 20 const setValue = (value) => { 21 try { 22 // Allow value to be a function so we have the same API as useState 23 const valueToStore = 24 value instanceof Function ? value(storedValue) : value; 25 26 // Save state 27 setStoredValue(valueToStore); 28 29 // Save to local storage 30 window.localStorage.setItem(key, JSON.stringify(valueToStore)); 31 } catch (error) { 32 console.error(error); 33 } 34 }; 35 36 // Sync with other browser tabs/windows 37 useEffect(() => { 38 function handleStorageChange(event) { 39 if (event.key === key) { 40 setStoredValue(JSON.parse(event.newValue || 'null')); 41 } 42 } 43 44 // Listen for changes in other windows 45 window.addEventListener('storage', handleStorageChange); 46 47 return () => { 48 window.removeEventListener('storage', handleStorageChange); 49 }; 50 }, [key]); 51 52 return [storedValue, setValue]; 53}
Using the local storage hook:
1function DarkModeToggle() { 2 const [darkMode, setDarkMode] = useLocalStorage('darkMode', false); 3 4 useEffect(() => { 5 document.body.classList.toggle('dark-mode', darkMode); 6 }, [darkMode]); 7 8 return ( 9 <button onClick={() => setDarkMode(!darkMode)}> 10 Toggle {darkMode ? 'Light' : 'Dark'} Mode 11 </button> 12 ); 13}
Form validation can get complex. Here's a hook to handle it:
1import { useState, useEffect } from 'react'; 2 3function useFormValidation(initialValues, validate) { 4 const [values, setValues] = useState(initialValues); 5 const [errors, setErrors] = useState({}); 6 const [touched, setTouched] = useState({}); 7 const [isSubmitting, setIsSubmitting] = useState(false); 8 9 // Validate whenever values or touched fields change 10 useEffect(() => { 11 if (Object.keys(touched).length > 0) { 12 const validationErrors = validate(values); 13 setErrors(validationErrors); 14 } 15 }, [values, touched, validate]); 16 17 // Check if form is valid when errors change 18 useEffect(() => { 19 if (isSubmitting && Object.keys(errors).length === 0) { 20 // Form is valid and being submitted 21 onSubmitCallback(); 22 } 23 24 setIsSubmitting(false); 25 }, [errors, isSubmitting]); 26 27 const handleChange = (event) => { 28 const { name, value } = event.target; 29 30 setValues({ 31 ...values, 32 [name]: value 33 }); 34 }; 35 36 const handleBlur = (event) => { 37 const { name } = event.target; 38 39 setTouched({ 40 ...touched, 41 [name]: true 42 }); 43 }; 44 45 const handleSubmit = (onSubmit) => { 46 return (event) => { 47 event.preventDefault(); 48 49 // Mark all fields as touched 50 const allTouched = Object.keys(values).reduce((acc, field) => { 51 acc[field] = true; 52 return acc; 53 }, {}); 54 55 setTouched(allTouched); 56 setIsSubmitting(true); 57 onSubmitCallback = onSubmit; 58 }; 59 }; 60 61 const reset = () => { 62 setValues(initialValues); 63 setErrors({}); 64 setTouched({}); 65 setIsSubmitting(false); 66 }; 67 68 // For closure access to callback 69 let onSubmitCallback = () => {}; 70 71 return { 72 values, 73 errors, 74 touched, 75 handleChange, 76 handleBlur, 77 handleSubmit, 78 reset, 79 isSubmitting 80 }; 81}
Using the form validation hook:
1function SignupForm() { 2 const validate = (values) => { 3 const errors = {}; 4 5 if (!values.email) { 6 errors.email = 'Email is required'; 7 } else if (!/\S+@\S+\.\S+/.test(values.email)) { 8 errors.email = 'Email is invalid'; 9 } 10 11 if (!values.password) { 12 errors.password = 'Password is required'; 13 } else if (values.password.length < 8) { 14 errors.password = 'Password must be at least 8 characters'; 15 } 16 17 return errors; 18 }; 19 20 const { 21 values, 22 errors, 23 touched, 24 handleChange, 25 handleBlur, 26 handleSubmit, 27 isSubmitting 28 } = useFormValidation( 29 { email: '', password: '' }, 30 validate 31 ); 32 33 const onSubmit = () => { 34 console.log('Form submitted with:', values); 35 // API call would go here 36 }; 37 38 return ( 39 <form onSubmit={handleSubmit(onSubmit)}> 40 <div> 41 <label>Email:</label> 42 <input 43 type="email" 44 name="email" 45 value={values.email} 46 onChange={handleChange} 47 onBlur={handleBlur} 48 /> 49 {touched.email && errors.email && ( 50 <div className="error">{errors.email}</div> 51 )} 52 </div> 53 54 <div> 55 <label>Password:</label> 56 <input 57 type="password" 58 name="password" 59 value={values.password} 60 onChange={handleChange} 61 onBlur={handleBlur} 62 /> 63 {touched.password && errors.password && ( 64 <div className="error">{errors.password}</div> 65 )} 66 </div> 67 68 <button type="submit" disabled={isSubmitting}> 69 Sign Up 70 </button> 71 </form> 72 ); 73}
One of the most powerful aspects of custom hooks is their composability. You can build complex hooks by combining simpler ones:
1function useUserProfile(userId) { 2 // Reuse the fetch hook we defined earlier 3 const { data, loading, error } = useFetch(`/api/users/${userId}`); 4 5 // Reuse our localStorage hook to track last viewed profile 6 const [lastViewedProfile, setLastViewedProfile] = useLocalStorage( 7 'lastViewedProfile', 8 null 9 ); 10 11 // Set last viewed profile when data loads 12 useEffect(() => { 13 if (data && !loading) { 14 setLastViewedProfile(userId); 15 } 16 }, [data, loading, userId, setLastViewedProfile]); 17 18 return { 19 user: data, 20 loading, 21 error, 22 lastViewedProfile 23 }; 24}
Custom hooks can be tested independently from components using tools like @testing-library/react-hooks
:
1import { renderHook, act } from '@testing-library/react-hooks'; 2import useCounter from './useCounter'; 3 4test('should increment counter', () => { 5 const { result } = renderHook(() => useCounter(0)); 6 7 act(() => { 8 result.current.increment(); 9 }); 10 11 expect(result.current.count).toBe(1); 12});
Always start custom hook names with "use" to follow React's convention. This ensures:
1// Good 2function useWindowSize() { /* ... */ } 3 4// Bad 5function getWindowSize() { /* ... */ }
Each hook should have a single responsibility:
1// Good: Focused hooks 2function useDocumentTitle(title) { /* ... */ } 3function useWindowWidth() { /* ... */ } 4 5// Bad: Trying to do too much 6function useDocumentStuff() { /* ... */ } // Too vague and unfocused
Hooks can return values in different ways:
1// Array return (good for renaming at use site) 2const [count, setCount] = useCounter(0); 3 4// Object return (good for multiple values) 5const { width, height } = useWindowSize();
Make hooks easy to use by providing sensible defaults:
1function useLocalStorage(key, initialValue = null) { /* ... */ }
Don't let hooks crash the application:
1function useFetch(url) { 2 // ... 3 useEffect(() => { 4 async function fetchData() { 5 try { 6 // Fetch logic here 7 } catch (error) { 8 // Handle errors gracefully 9 setError(error.message); 10 console.error('Fetch error:', error); 11 } finally { 12 setLoading(false); 13 } 14 } 15 // ... 16 }, [url]); 17 // ... 18}
Let's explore some practical custom hooks that solve common problems:
1import { useState, useEffect } from 'react'; 2 3function useMediaQuery(query) { 4 const [matches, setMatches] = useState( 5 () => window.matchMedia(query).matches 6 ); 7 8 useEffect(() => { 9 const mediaQuery = window.matchMedia(query); 10 11 const handleChange = (event) => { 12 setMatches(event.matches); 13 }; 14 15 // Initial check 16 setMatches(mediaQuery.matches); 17 18 // Modern browsers 19 mediaQuery.addEventListener('change', handleChange); 20 21 return () => { 22 mediaQuery.removeEventListener('change', handleChange); 23 }; 24 }, [query]); 25 26 return matches; 27} 28 29// Usage 30function ResponsiveComponent() { 31 const isMobile = useMediaQuery('(max-width: 768px)'); 32 33 return ( 34 <div> 35 {isMobile ? ( 36 <MobileLayout /> 37 ) : ( 38 <DesktopLayout /> 39 )} 40 </div> 41 ); 42}
1import { useEffect } from 'react'; 2 3function useOnClickOutside(ref, handler) { 4 useEffect(() => { 5 const listener = (event) => { 6 // Do nothing if clicking ref's element or descendent elements 7 if (!ref.current || ref.current.contains(event.target)) { 8 return; 9 } 10 11 handler(event); 12 }; 13 14 document.addEventListener('mousedown', listener); 15 document.addEventListener('touchstart', listener); 16 17 return () => { 18 document.removeEventListener('mousedown', listener); 19 document.removeEventListener('touchstart', listener); 20 }; 21 }, [ref, handler]); 22} 23 24// Usage 25function DropdownMenu() { 26 const [isOpen, setIsOpen] = useState(false); 27 const ref = useRef(); 28 29 // Close the dropdown when clicking outside 30 useOnClickOutside(ref, () => setIsOpen(false)); 31 32 return ( 33 <div ref={ref}> 34 <button onClick={() => setIsOpen(!isOpen)}>Toggle</button> 35 {isOpen && ( 36 <ul className="dropdown-menu"> 37 <li>Option 1</li> 38 <li>Option 2</li> 39 </ul> 40 )} 41 </div> 42 ); 43}
1import { useState, useEffect } from 'react'; 2 3function useDebouncedValue(value, delay) { 4 const [debouncedValue, setDebouncedValue] = useState(value); 5 6 useEffect(() => { 7 // Update debounced value after delay 8 const handler = setTimeout(() => { 9 setDebouncedValue(value); 10 }, delay); 11 12 // Cancel the timeout if value changes or component unmounts 13 return () => { 14 clearTimeout(handler); 15 }; 16 }, [value, delay]); 17 18 return debouncedValue; 19} 20 21// Usage 22function SearchComponent() { 23 const [searchTerm, setSearchTerm] = useState(''); 24 const debouncedSearchTerm = useDebouncedValue(searchTerm, 500); 25 const [results, setResults] = useState([]); 26 27 // Effect will only run when the debounced value changes 28 useEffect(() => { 29 if (debouncedSearchTerm) { 30 fetchSearchResults(debouncedSearchTerm).then(data => { 31 setResults(data); 32 }); 33 } else { 34 setResults([]); 35 } 36 }, [debouncedSearchTerm]); 37 38 return ( 39 <div> 40 <input 41 value={searchTerm} 42 onChange={e => setSearchTerm(e.target.value)} 43 placeholder="Search..." 44 /> 45 {/* Results list */} 46 </div> 47 ); 48}
Let's see how custom hooks can structure a complete feature in a React application. Here's a todo list application using several custom hooks:
1// Step 1: Create focused custom hooks 2 3// Hook for managing todos in localStorage 4function useTodos() { 5 const [todos, setTodos] = useLocalStorage('todos', []); 6 7 const addTodo = (text) => { 8 setTodos([ 9 ...todos, 10 { 11 id: Date.now(), 12 text, 13 completed: false 14 } 15 ]); 16 }; 17 18 const toggleTodo = (id) => { 19 setTodos( 20 todos.map(todo => 21 todo.id === id ? { ...todo, completed: !todo.completed } : todo 22 ) 23 ); 24 }; 25 26 const deleteTodo = (id) => { 27 setTodos(todos.filter(todo => todo.id !== id)); 28 }; 29 30 return { 31 todos, 32 addTodo, 33 toggleTodo, 34 deleteTodo 35 }; 36} 37 38// Hook for form handling 39function useInputField(initialValue = '') { 40 const [value, setValue] = useState(initialValue); 41 42 const handleChange = (e) => { 43 setValue(e.target.value); 44 }; 45 46 const reset = () => { 47 setValue(initialValue); 48 }; 49 50 return { 51 value, 52 onChange: handleChange, 53 reset 54 }; 55} 56 57// Hook for tracking filter state 58function useFilteredTodos(todos) { 59 const [filter, setFilter] = useState('all'); 60 61 const filteredTodos = useMemo(() => { 62 switch (filter) { 63 case 'active': 64 return todos.filter(todo => !todo.completed); 65 case 'completed': 66 return todos.filter(todo => todo.completed); 67 default: 68 return todos; 69 } 70 }, [todos, filter]); 71 72 return { 73 filter, 74 setFilter, 75 filteredTodos 76 }; 77} 78 79// Step 2: Compose them in a component 80 81function TodoApp() { 82 const { todos, addTodo, toggleTodo, deleteTodo } = useTodos(); 83 const { value: newTodoText, onChange: setNewTodoText, reset: resetNewTodoText } = useInputField(); 84 const { filter, setFilter, filteredTodos } = useFilteredTodos(todos); 85 86 const handleSubmit = (e) => { 87 e.preventDefault(); 88 if (!newTodoText.trim()) return; 89 90 addTodo(newTodoText); 91 resetNewTodoText(); 92 }; 93 94 return ( 95 <div className="todo-app"> 96 <h1>Todo List</h1> 97 98 <form onSubmit={handleSubmit}> 99 <input 100 type="text" 101 value={newTodoText} 102 onChange={setNewTodoText} 103 placeholder="Add a todo..." 104 /> 105 <button type="submit">Add</button> 106 </form> 107 108 <div className="filters"> 109 <button 110 className={filter === 'all' ? 'active' : ''} 111 onClick={() => setFilter('all')} 112 > 113 All 114 </button> 115 <button 116 className={filter === 'active' ? 'active' : ''} 117 onClick={() => setFilter('active')} 118 > 119 Active 120 </button> 121 <button 122 className={filter === 'completed' ? 'active' : ''} 123 onClick={() => setFilter('completed')} 124 > 125 Completed 126 </button> 127 </div> 128 129 <ul className="todo-list"> 130 {filteredTodos.map(todo => ( 131 <li key={todo.id} className={todo.completed ? 'completed' : ''}> 132 <input 133 type="checkbox" 134 checked={todo.completed} 135 onChange={() => toggleTodo(todo.id)} 136 /> 137 <span>{todo.text}</span> 138 <button onClick={() => deleteTodo(todo.id)}>Delete</button> 139 </li> 140 ))} 141 </ul> 142 143 <div className="todo-count"> 144 <p>{todos.filter(todo => !todo.completed).length} items left</p> 145 </div> 146 </div> 147 ); 148}
This example demonstrates:
When a hook is not properly memoizing values or dependencies are missing:
1// Problem: Creates new function on every render 2function useProblematic() { 3 const [count, setCount] = useState(0); 4 5 // This effect runs on every render because fetch is recreated each time 6 useEffect(() => { 7 const fetchData = async () => { 8 // Fetch data 9 }; 10 11 fetchData(); 12 }, [fetchData]); // Dependency changes on every render! 13 14 return count; 15} 16 17// Solution: Use useCallback 18function useFixed() { 19 const [count, setCount] = useState(0); 20 21 const fetchData = useCallback(async () => { 22 // Fetch data 23 }, []); // Stable function reference 24 25 useEffect(() => { 26 fetchData(); 27 }, [fetchData]); // Now dependency is stable 28 29 return count; 30}
When a hook captures outdated values:
1// Problem: Timer uses stale count value 2function useProblematicTimer() { 3 const [count, setCount] = useState(0); 4 5 useEffect(() => { 6 const timer = setInterval(() => { 7 // This always sees the initial value of count (0) 8 setCount(count + 1); 9 }, 1000); 10 11 return () => clearInterval(timer); 12 }, []); // Empty deps means this closure captures initial count 13 14 return count; 15} 16 17// Solution: Use functional updates 18function useFixedTimer() { 19 const [count, setCount] = useState(0); 20 21 useEffect(() => { 22 const timer = setInterval(() => { 23 // This always gets the latest count 24 setCount(prevCount => prevCount + 1); 25 }, 1000); 26 27 return () => clearInterval(timer); 28 }, []); // Empty deps is fine now 29 30 return count; 31}
Custom hooks are a powerful way to abstract and reuse logic in React applications. They solve common problems like code duplication, separation of concerns, and testability, while maintaining React's compositional model.
By creating your own custom hooks, you can:
As you build your React applications, look for opportunities to abstract repeated logic into custom hooks. Start with small, focused hooks and compose them together to build more complex functionality.
Remember that hooks are just functions, but by following React's conventions and the guidelines in this lesson, you can create powerful abstractions that make your code cleaner, more maintainable, and more reusable.