Table of Contents
- Introduction to Custom React Hooks
- Why Create Custom React Hooks?
- Core Principles of Custom Hooks
- Getting Started: Setting Up Your Environment
- Step-by-Step: Building Your First Custom Hook
- Advanced Custom Hooks Examples
- Best Practices for Custom Hooks
- Common Pitfalls and How to Avoid Them
- Testing Custom Hooks
- Conclusion
- References
Introduction to Custom React Hooks
At their core, custom React hooks are JavaScript functions that follow specific conventions and leverage React’s built-in hooks to encapsulate and reuse stateful logic across multiple components. They enable you to extract component logic into reusable, modular functions, making your codebase cleaner, more maintainable, and easier to test.
By convention, custom hooks always start with the prefix use (e.g., useLocalStorage, useFetch). This naming convention is critical: it signals to React (and other developers) that the function follows the Rules of Hooks (more on this later) and can use other hooks internally.
Why Create Custom React Hooks?
Custom hooks solve a common problem in React development: repeating stateful logic across components. For example, if multiple components need to fetch data from an API, handle form validation, or sync state with localStorage, writing that logic from scratch in each component leads to duplication and bloated components.
Benefits of Custom Hooks:
- Reusability: Share logic across components, pages, or even projects.
- Separation of Concerns: Keep components focused on UI rendering while hooks handle logic.
- Testability: Test hooks in isolation without rendering full components.
- Cleaner Code: Reduce component complexity by extracting logic into dedicated hooks.
Core Principles of Custom Hooks
To create effective custom hooks, follow these fundamental principles:
1. Follow the Rules of Hooks
Custom hooks must adhere to React’s Rules of Hooks:
- Only call hooks at the top level: Never call hooks inside loops, conditions, or nested functions.
- Only call hooks from React functions: Call hooks from React functional components or other custom hooks (not regular JavaScript functions).
2. Naming Convention: Start with use
Always name custom hooks starting with use (e.g., useDarkMode, useScrollPosition). This ensures React can enforce the Rules of Hooks and makes your code more readable for other developers.
3. Single Responsibility Principle
Each custom hook should focus on one specific task. For example, useFetch handles data fetching, useForm manages form state, and useLocalStorage syncs state with localStorage. Avoid “kitchen sink” hooks that try to do too much—they become hard to maintain and test.
Getting Started: Setting Up Your Environment
Before diving into custom hooks, let’s set up a basic React project. We’ll use Vite for fast development (you can also use Create React App):
# Create a new React project with Vite
npm create vite@latest custom-hooks-demo -- --template react
cd custom-hooks-demo
npm install
npm run dev
Your project structure will look like this:
custom-hooks-demo/
├── src/
│ ├── hooks/ # We’ll add custom hooks here
│ ├── components/ # Components using hooks
│ ├── App.jsx
│ └── main.jsx
Create a hooks folder in src to organize your custom hooks.
Step-by-Step: Building Your First Custom Hook
Let’s start with a practical example: useLocalStorage. This hook syncs state with the browser’s localStorage, ensuring data persists across page refreshes.
Step 1: Define the Hook Logic
The useLocalStorage hook will:
- Accept a
key(forlocalStorage) and aninitialValue. - Initialize state with the value from
localStorage(orinitialValueif none exists). - Update
localStoragewhenever the state changes.
Create src/hooks/useLocalStorage.js:
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Initialize state with localStorage value (or initialValue)
const [value, setValue] = useState(() => {
try {
// Get from localStorage by key
const item = window.localStorage.getItem(key);
// Parse stored JSON or return initialValue if null
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// Handle errors (e.g., invalid JSON)
console.error("Error reading localStorage key:", key, error);
return initialValue;
}
});
// Update localStorage whenever value changes
useEffect(() => {
try {
// Save to localStorage
window.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error("Error writing to localStorage key:", key, error);
}
}, [key, value]); // Re-run effect when key or value changes
// Return state and setter (like useState)
return [value, setValue];
}
export default useLocalStorage;
Step 2: Use the Hook in a Component
Now, let’s use useLocalStorage in a component. For example, a theme toggler that remembers the user’s preference:
Create src/components/ThemeToggle.jsx:
import useLocalStorage from '../hooks/useLocalStorage';
function ThemeToggle() {
// Use the custom hook: key is "theme", initial value is "light"
const [theme, setTheme] = useLocalStorage('theme', 'light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<div style={{
background: theme === 'light' ? '#fff' : '#333',
color: theme === 'light' ? '#333' : '#fff',
padding: '2rem',
minHeight: '100vh'
}}>
<h1>Current Theme: {theme}</h1>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
export default ThemeToggle;
Import ThemeToggle into App.jsx:
import ThemeToggle from './components/ThemeToggle';
function App() {
return (
<div className="App">
<ThemeToggle />
</div>
);
}
export default App;
Run npm run dev—toggling the theme will now persist across page refreshes!
Advanced Custom Hooks Examples
Let’s explore three powerful custom hooks for common use cases: data fetching, form handling, and media queries.
1. useFetch: Data Fetching with Loading/Error States
Fetching data is a common task—useFetch abstracts loading, error, and data states into a reusable hook.
src/hooks/useFetch.js:
import { useState, useEffect, useRef } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null); // To cancel pending requests
useEffect(() => {
// Create a new AbortController for each request
abortControllerRef.current = new AbortController();
const { signal } = abortControllerRef.current;
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, { ...options, signal });
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') { // Ignore abort errors on cleanup
setError(err.message);
setData(null);
}
} finally {
if (!signal.aborted) setLoading(false); // Only update if not aborted
}
};
fetchData();
// Cleanup: Abort pending request when component unmounts or url/options change
return () => abortControllerRef.current?.abort();
}, [url, options]);
return { data, loading, error };
}
export default useFetch;
Usage in a Component:
import useFetch from '../hooks/useFetch';
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
2. useForm: Form Handling with Validation
useForm simplifies form state management, including input binding and validation.
src/hooks/useForm.js:
import { useState } from 'react';
function useForm(initialValues, validateForm) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (onSubmit) => async (e) => {
e.preventDefault();
setIsSubmitting(true);
// Run validation
const formErrors = validateForm ? validateForm(values) : {};
setErrors(formErrors);
// Submit only if no errors
if (Object.keys(formErrors).length === 0 && onSubmit) {
await onSubmit(values);
}
setIsSubmitting(false);
};
return { values, errors, isSubmitting, handleChange, handleSubmit };
}
export default useForm;
Usage in a Component:
import useForm from '../hooks/useForm';
function LoginForm() {
const validate = (values) => {
const errors = {};
if (!values.email) errors.email = "Email is required";
if (!values.password) errors.password = "Password is required";
return errors;
};
const { values, errors, isSubmitting, handleChange, handleSubmit } = useForm(
{ email: '', password: '' },
validate
);
const onSubmit = async (values) => {
console.log("Submitting:", values);
// API call to login...
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
name="email"
value={values.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && <p className="error">{errors.email}</p>}
<input
name="password"
type="password"
value={values.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && <p className="error">{errors.password}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Logging in..." : "Login"}
</button>
</form>
);
}
3. useMediaQuery: Responsive Screen Detection
useMediaQuery checks if a CSS media query matches (e.g., “min-width: 768px”) and updates dynamically.
src/hooks/useMediaQuery.js:
import { useState, useEffect } from 'react';
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);
const handleChange = (e) => setMatches(e.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
export default useMediaQuery;
Usage in a Component:
import useMediaQuery from '../hooks/useMediaQuery';
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 767px)');
return (
<div>
{isMobile ? (
<p>Mobile view (≤767px)</p>
) : (
<p>Desktop view (>767px)</p>
)}
</div>
);
}
Best Practices for Custom Hooks
To create high-quality custom hooks, follow these best practices:
1. Document Your Hooks
Use JSDoc to explain what the hook does, its parameters, return values, and usage examples.
Example for useLocalStorage:
/**
* Syncs state with localStorage and persists across page refreshes.
* @param {string} key - The localStorage key to use.
* @param {any} initialValue - The initial value if no item exists in localStorage.
* @returns {[any, (value: any) => void]} A tuple with the current value and a setter function.
*/
function useLocalStorage(key, initialValue) { ... }
2. Compose Hooks for Complex Logic
Combine smaller hooks to build more powerful ones. For example, useFetch could use useLocalStorage to cache responses:
function useFetchWithCache(url) {
const { data: cachedData } = useLocalStorage(`cache_${url}`);
const { data: freshData, loading, error } = useFetch(url);
// Update cache when fresh data loads
useEffect(() => {
if (freshData) useLocalStorage(`cache_${url}`, freshData);
}, [freshData, url]);
return { data: cachedData || freshData, loading, error };
}
3. Handle Edge Cases
Account for errors (e.g., localStorage unavailable in private browsing), default values, and cleanup (e.g., aborting fetch requests).
4. Avoid Over-Engineering
Don’t create a hook for trivial logic. For example, a hook that just wraps useState adds unnecessary complexity. Reserve hooks for reusable, non-trivial logic.
Common Pitfalls and How to Avoid Them
1. Breaking the Rules of Hooks
Problem: Calling hooks inside loops, conditionals, or nested functions.
Fix: Always call hooks at the top level of your custom hook or component.
2. Poor Naming
Problem: Not starting hooks with use (e.g., localStorageHook).
Fix: Rename to useLocalStorage to follow conventions and enable React’s linting rules.
3. Unnecessary Re-Renders
Problem: Hooks that trigger frequent updates (e.g., useFetch re-fetching on every render).
Fix: Memoize dependencies with useCallback/useMemo if needed, or ensure useEffect dependencies are stable.
4. Missing Cleanup
Problem: Leaving side effects (e.g., subscriptions, fetch requests) active after a component unmounts.
Fix: Return a cleanup function from useEffect to cancel requests or remove event listeners.
Testing Custom Hooks
Test custom hooks in isolation using @testing-library/react-hooks, which provides utilities to render hooks and assert their behavior.
Example Test for useLocalStorage:
npm install --save-dev @testing-library/react-hooks
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear(); // Reset localStorage before each test
});
it('initializes with initialValue when no localStorage item exists', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
expect(result.current[0]).toBe('initial');
});
it('updates localStorage when state changes', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'initial'));
act(() => {
result.current[1]('updated'); // Call setter
});
expect(localStorage.getItem('testKey')).toBe(JSON.stringify('updated'));
expect(result.current[0]).toBe('updated');
});
});
Conclusion
Custom React hooks are a powerful tool for encapsulating and reusing stateful logic. By following the Rules of Hooks, focusing on single responsibility, and adhering to best practices, you can create hooks that simplify your components, improve code reusability, and enhance maintainability.
Start small—identify repetitive logic in your components, extract it into a hook, and iterate. Over time, you’ll build a library of custom hooks tailored to your project’s needs, making you a more efficient React developer.