React has revolutionized front-end development with its component-based architecture, and hooks—introduced in React 16.8—have further simplified state management and side-effect handling in functional components. While React provides built-in hooks like useState and useEffect, custom hooks empower you to extract and reuse component logic across multiple components. In this guide, we’ll dive deep into creating custom hooks, from the basics to advanced patterns, with practical examples and best practices.
Table of Contents
- Introduction to React Hooks
- Rules of Hooks
- Why Create Custom Hooks?
- How to Create a Custom Hook: Step-by-Step
- Example 2: Form Handling with
useForm - Advanced Custom Hook Patterns
- Best Practices for Custom Hooks
- Common Pitfalls to Avoid
- Conclusion
- References
Rules of Hooks
Custom hooks are built on top of React’s built-in hooks, so they must follow the same rules:
- Only call hooks at the top level: Don’t call hooks inside loops, conditions, or nested functions. This ensures hooks are called in the same order every time a component renders.
- Only call hooks from React functions: Call hooks in functional components or other custom hooks (never in regular JavaScript functions).
Why Create Custom Hooks?
Custom hooks solve a critical problem: reusing stateful logic between components. Without custom hooks, you might duplicate logic across components (e.g., data fetching, form handling) or use complex patterns like render props or higher-order components (HOCs). Custom hooks:
- Promote code reuse: Extract logic into a single function and reuse it across components.
- Improve readability: Simplify component code by moving complex logic to a dedicated hook.
- Encapsulate logic: Hide implementation details, making components focused on UI.
How to Create a Custom Hook: Step-by-Step
Creating a custom hook is straightforward:
- Name the function starting with
use: This convention tells React (and other developers) that the function is a hook and follows hook rules. - Extract reusable logic: Move stateful logic (e.g.,
useState,useEffect) from a component into the custom hook. - Return values the component needs: The hook can return state variables, functions, or other values that the component will use.
Example 1: Data Fetching with useFetch
Let’s build a useFetch hook to handle data fetching logic. This is a common use case, as many components need to fetch data from APIs.
Problem: Duplicate Fetch Logic
Without a custom hook, you might repeat this code in every component that fetches data:
// UserComponent.jsx
import { useState, useEffect } from 'react';
function UserComponent({ 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(`https://api.example.com/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
const data = await response.json();
setUser(data);
setError(null);
} catch (err) {
setError(err.message);
setUser(null);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]); // Re-run effect when userId changes
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>User: {user.name}</div>;
}
This works, but if another component (e.g., PostComponent) needs similar fetch logic, you’d duplicate most of this code.
Solution: Extract Logic into useFetch
Let’s create useFetch to encapsulate the fetch logic:
// useFetch.js
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Abort controller to cancel pending requests
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
const result = await response.json();
setData(result);
setError(null);
} catch (err) {
// Ignore abort errors (component unmounted)
if (err.name !== 'AbortError') {
setError(err.message);
setData(null);
}
} finally {
// Only update loading if the request wasn't aborted
if (!abortController.signal.aborted) {
setLoading(false);
}
}
};
fetchData();
// Cleanup: abort request if component unmounts or url changes
return () => abortController.abort();
}, [url]); // Re-run effect when url changes
return { data, loading, error }; // Return values for components
}
export default useFetch;
Using useFetch in Components
Now, any component can reuse this logic with just a few lines:
// UserComponent.jsx
import useFetch from './useFetch';
function UserComponent({ userId }) {
const { data: user, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>User: {user.name}</div>;
}
// PostComponent.jsx
import useFetch from './useFetch';
function PostComponent({ postId }) {
const { data: post, loading, error } = useFetch(`https://api.example.com/posts/${postId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>Post Title: {post.title}</div>;
}
Key Improvements:
- No duplicated fetch logic.
- Built-in cleanup with
AbortControllerto prevent memory leaks. - Consistent error/loading handling across components.
Example 2: Form Handling with useForm
Another common use case is form state management. Let’s build a useForm hook to simplify form handling.
Step 1: Create useForm
// useForm.js
import { useState } from 'react';
function useForm(initialValues) {
// Initialize form state with initial values
const [values, setValues] = useState(initialValues);
// Update state when input values change
const handleChange = (e) => {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value, // Dynamic key based on input name
}));
};
// Reset form to initial values
const resetForm = () => {
setValues(initialValues);
};
return { values, handleChange, resetForm }; // Return state and helpers
}
export default useForm;
Step 2: Use useForm in a Component
// LoginForm.jsx
import useForm from './useForm';
function LoginForm() {
// Initialize form with default values
const { values, handleChange, resetForm } = useForm({
email: '',
password: '',
});
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form submitted:', values);
// Reset form after submission
resetForm();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Email:</label>
<input
type="email"
name="email" // Must match state key
value={values.email}
onChange={handleChange}
required
/>
</div>
<div>
<label>Password:</label>
<input
type="password"
name="password" // Must match state key
value={values.password}
onChange={handleChange}
required
/>
</div>
<button type="submit">Login</button>
<button type="button" onClick={resetForm}>Reset</button>
</form>
);
}
export default LoginForm;
Why This Works:
useFormabstracts state updates and reset logic.handleChangedynamically updates the state based on the input’snameattribute, eliminating the need for separate handlers for each field.
Advanced Custom Hook Patterns
Custom hooks can handle more complex scenarios, such as:
Custom Hooks with Parameters
Hooks can accept parameters to make them flexible. For example, extend useFetch to accept custom headers:
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
// ... rest of logic ...
} catch (err) { /* ... */ }
};
fetchData();
return () => abortController.abort();
}, [url, options]); // Include options in dependencies
return { data, loading, error };
}
// Usage: Pass headers for authenticated requests
const { data } = useFetch('/api/protected', {
headers: { Authorization: `Bearer ${token}` }
});
Composing Custom Hooks
Hooks can call other hooks to build more complex logic. For example, a useLocalStorage hook to persist state, and a useUserPreferences hook that uses it:
// useLocalStorage.js: Persist state to localStorage
function useLocalStorage(key, initialValue) {
// Initialize state with localStorage value (or initialValue)
const [value, setValue] = useState(() => {
const storedValue = localStorage.getItem(key);
return storedValue ? JSON.parse(storedValue) : initialValue;
});
// Update localStorage when value changes
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
}
// useUserPreferences.js: Compose useLocalStorage
function useUserPreferences() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [notifications, setNotifications] = useLocalStorage('notifications', true);
return { theme, setTheme, notifications, setNotifications };
}
// Usage in a component
function Settings() {
const { theme, setTheme } = useUserPreferences();
return (
<div>
<p>Theme: {theme}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</div>
);
}
Hooks with Cleanup Logic
Hooks often need to clean up side effects (e.g., subscriptions, timers). Use the cleanup function returned by useEffect for this:
// useTimer.js: Track elapsed time
function useTimer(initialSeconds = 0) {
const [seconds, setSeconds] = useState(initialSeconds);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
// Cleanup: Clear interval when component unmounts
return () => clearInterval(intervalId);
}, []); // Empty dependency array: run once on mount
return seconds;
}
// Usage
function TimerDisplay() {
const seconds = useTimer();
return <div>Elapsed: {seconds}s</div>;
}
Best Practices for Custom Hooks
- Follow the
usenaming convention: Start hook names withuse(e.g.,useFetch,useForm). This makes hooks easy to identify and ensures linter support. - Single responsibility principle: Each hook should handle one type of logic (e.g.,
useFetchfor data fetching,useFormfor forms). Avoid “god hooks” that do too much. - Document the hook: Add JSDoc comments to explain what the hook does, its parameters, and return values. For example:
/** * Custom hook to fetch data from a URL. * @param {string} url - The API endpoint to fetch. * @returns {Object} { data, loading, error } */ function useFetch(url) { /* ... */ } - Handle edge cases: Include error handling, loading states, and cleanup (e.g.,
AbortControllerinuseFetch). - Test hooks in isolation: Use tools like
@testing-library/react-hooksto test hooks independently of components.
Common Pitfalls to Avoid
- Violating hook rules: Never call hooks inside loops, conditions, or nested functions. This breaks React’s internal hook ordering.
- Overcomplicating hooks: Don’t create a custom hook for trivial logic (e.g., a single
useStatecall). Reserve hooks for reusable, non-trivial logic. - Missing dependencies in
useEffect: Always include all variables used insideuseEffectin the dependency array (use the React linter to catch this). - Ignoring cleanup: Forgetting to clean up side effects (e.g., intervals, subscriptions) can cause memory leaks or stale state.
Conclusion
Custom hooks are a powerful way to reuse stateful logic in React. By encapsulating logic into reusable functions, you can write cleaner, more maintainable code and avoid duplication. Start with simple hooks (like useFetch or useForm), then experiment with advanced patterns like composition and parameterized hooks.
Remember: The best custom hooks solve a specific problem and follow the rules of hooks. With practice, you’ll find endless ways to simplify your React components!