Self-paced

Explore our extensive collection of courses designed to help you master various subjects and skills. Whether you're a beginner or an advanced learner, there's something here for everyone.

Bootcamp

Learn live

Join us for our free workshops, webinars, and other events to learn more about our programs and get started on your journey to becoming a developer.

Upcoming live events

Learning library

For all the self-taught geeks out there, here is our content library with most of the learning materials we have produced throughout the years.

It makes sense to start learning by reading and watching videos about fundamentals and how things work.

Search from all Lessons


← Back to Lessons

Custom Hooks in React: Building Reusable Logic

What Are Custom Hooks?
Common Patterns for Custom Hooks

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.

What Are Custom Hooks?

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:

  • Start with the word "use" (e.g., useFormValidation, useLocalStorage)
  • Can call other hooks (built-in or custom)
  • Share logic, not state or effects (each component using the hook maintains its own state)
  • Follow the same rules as React's built-in hooks

Why Create Custom Hooks?

Custom hooks solve several common problems in React development:

  1. DRY (Don't Repeat Yourself): Extract duplicated logic from multiple components
  2. Separation of Concerns: Isolate unrelated logic that might otherwise be tangled together
  3. Abstraction: Hide complex implementation details behind a simple interface
  4. Testability: Test complex logic independently from components
  5. Composition: Combine multiple hooks into more powerful abstractions

Creating Your First Custom Hook

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:

  1. Encapsulates the state logic
  2. Provides a clean interface (value, onChange, reset)
  3. Can be reused for multiple form fields
  4. Each instance maintains its own state

Common Patterns for Custom Hooks

1. Data Fetching Hook

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}

2. Local Storage Hook

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}

3. Form Validation Hook

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}

Composing Custom Hooks

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}

Testing Custom Hooks

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});

Best Practices for Custom Hooks

1. Follow the Naming Convention

Always start custom hook names with "use" to follow React's convention. This ensures:

  • Code linters can apply rules of hooks
  • Other developers immediately recognize it as a hook
  • Separation from regular utility functions
1// Good 2function useWindowSize() { /* ... */ } 3 4// Bad 5function getWindowSize() { /* ... */ }

2. Keep Hooks Focused

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

3. Return a Consistent Interface

Hooks can return values in different ways:

  • Array for state-like hooks (good for renaming)
  • Object for complex data with named properties
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();

4. Provide Good Defaults

Make hooks easy to use by providing sensible defaults:

1function useLocalStorage(key, initialValue = null) { /* ... */ }

5. Handle Errors Gracefully

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}

Real-World Custom Hooks Examples

Let's explore some practical custom hooks that solve common problems:

useMediaQuery: Responsive Design Hook

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}

useOnClickOutside: Detect Clicks Outside an Element

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}

useDebouncedValue: Debounce Input Values

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}

Building a Complete Application with Custom Hooks

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:

  1. Separation of concerns with focused hooks
  2. Composition of hooks to build a complete feature
  3. Maintaining component readability despite complex logic
  4. Reusable logic that could be used in other components

Troubleshooting Common Issues

1. Infinite Loops

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}

2. Stale Closures

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}

Conclusion

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:

  • Extract complex logic from components
  • Share functionality between components
  • Create more declarative, readable components
  • Test business logic independently
  • Build a library of reusable hooks for common patterns

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.