React has revolutionized how we build user interfaces, and one of its most powerful features is Hooks—functions that let you use state and other React features without writing a class. While React provides built-in hooks like useState and useEffect, there are times when you’ll find yourself repeating stateful logic across multiple components. This is where custom hooks shine: they let you extract and reuse stateful logic across components, making your code cleaner, more maintainable, and easier to test.
In this guide, we’ll dive deep into custom hooks—what they are, why they matter, how to build them, and best practices to avoid common pitfalls. By the end, you’ll be equipped to create your own custom hooks to solve real-world problems.
Table of Contents
- Introduction to React Hooks
- What Are Custom Hooks?
- Benefits of Custom Hooks
- Rules for Creating Custom Hooks
- Step-by-Step Guide to Creating a Custom Hook
- Practical Examples
- Advanced Tips for Custom Hooks
- Common Pitfalls to Avoid
- Conclusion
- References
What Are Custom Hooks?
A custom hook is a JavaScript function whose name starts with use and may call other hooks (built-in or custom). Custom hooks let you extract component logic into reusable functions, enabling you to share stateful logic across multiple components without duplicating code.
Key Characteristics:
- Name Convention: Must start with
use(e.g.,useLocalStorage,useFetch). This allows React to enforce hook rules (more on that later). - Reuses Logic, Not State: Custom hooks share logic, but each component that uses a custom hook gets its own isolated state. Two components using
useLocalStoragewon’t share the samelocalStoragekey unless explicitly designed to. - Composable: Can call other hooks (built-in or custom) to build complex logic from simpler pieces.
Benefits of Custom Hooks
Custom hooks offer several advantages for React development:
1. Reusability
Extract repeated stateful logic (e.g., data fetching, form validation) into a single hook, then reuse it across components, pages, or even projects.
2. Separation of Concerns
Cleanly separate component UI logic from “utility” logic (e.g., localStorage sync, window resizing). Components become focused on rendering, while hooks handle the “how” of state management.
3. Readability
Descriptive hook names (e.g., useFormValidation, useGeolocation) make code self-documenting. Instead of reading through 50 lines of state and effect logic in a component, you see const { isValid, errors } = useFormValidation(formData);.
4. Testability
Custom hooks are pure functions, making them easy to test in isolation (no need to render a component to test the logic).
Rules for Creating Custom Hooks
Custom hooks must follow React’s Rules of Hooks, which ensure hooks work correctly:
1. Only Call Hooks at the Top Level
Don’t call hooks inside loops, conditions, or nested functions. Hooks need to be called in the same order every time a component renders to ensure React can track state correctly.
2. Only Call Hooks from React Functions
Call hooks from:
- React functional components.
- Other custom hooks.
Never call hooks from regular JavaScript functions (e.g., event handlers, utility functions).
3. Name Must Start with use
This convention is enforced by React’s linter (via eslint-plugin-react-hooks), which checks for hook rule violations. Without this, React can’t distinguish custom hooks from regular functions, leading to bugs.
Step-by-Step Guide to Creating a Custom Hook
Let’s walk through building a custom hook from scratch. We’ll use useLocalStorage as an example—a hook that syncs state with localStorage, a common use case.
Step 1: Identify Repeated Logic
First, identify stateful logic that’s duplicated across components. For example, here’s a component that syncs a theme state with localStorage:
// ThemeComponent.js
import { useState, useEffect } from 'react';
function ThemeComponent() {
// Initialize state with localStorage value (or default)
const [theme, setTheme] = useState(() => {
const savedTheme = localStorage.getItem('theme');
return savedTheme ? JSON.parse(savedTheme) : 'light';
});
// Sync state with localStorage on change
useEffect(() => {
localStorage.setItem('theme', JSON.stringify(theme));
}, [theme]);
return (
<div>
<p>Current Theme: {theme}</p>
<button onClick={() => setTheme('light')}>Light</button>
<button onClick={() => setTheme('dark')}>Dark</button>
</div>
);
}
If another component (e.g., UserPreferencesComponent) needs to sync username with localStorage, you’d复制the useState and useEffect logic. Instead, extract this into a custom hook!
Step 2: Extract Logic into a Function Starting with use
Create a function named useLocalStorage (starts with use) and move the localStorage sync logic into it.
// useLocalStorage.js
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Logic to sync state with localStorage
}
Step 3: Use Built-in Hooks Inside
Inside useLocalStorage, use useState to manage the state and useEffect to sync with localStorage.
function useLocalStorage(key, initialValue) {
// Initialize state with localStorage value (or initialValue)
const [value, setValue] = useState(() => {
try {
// Get from localStorage by key
const item = localStorage.getItem(key);
// Parse stored JSON or return initialValue if null
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// Fallback to initialValue if error (e.g., invalid JSON)
console.error('Error reading localStorage key:', key, error);
return initialValue;
}
});
// Update localStorage whenever value changes
useEffect(() => {
try {
// Save to localStorage
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('Error writing to localStorage key:', key, error);
}
}, [key, value]); // Re-run effect if key or value changes
// Return state and setter (like useState)
return [value, setValue];
}
Step 4: Return Values Needed by Components
The hook should return values the component needs. Here, we return [value, setValue]—mimicking the useState API for familiarity.
Step 5: Use the Hook in Components
Now, any component can use useLocalStorage to sync state with localStorage in one line:
// ThemeComponent.js (refactored)
import useLocalStorage from './useLocalStorage';
function ThemeComponent() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<div>
<p>Current Theme: {theme}</p>
<button onClick={() => setTheme('light')}>Light</button>
<button onClick={() => setTheme('dark')}>Dark</button>
</div>
);
}
// UserPreferencesComponent.js
import useLocalStorage from './useLocalStorage';
function UserPreferencesComponent() {
const [username, setUsername] = useLocalStorage('username', 'guest');
return (
<div>
<p>Username: {username}</p>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
);
}
Boom! No more duplicated logic. Both components reuse useLocalStorage to sync state with localStorage.
Practical Examples
Let’s explore three more practical custom hooks to solidify your understanding.
Example 1: useFetch – Simplify Data Fetching
Fetching data with useEffect often requires managing data, loading, and error states. useFetch abstracts this logic.
Hook Implementation:
// useFetch.js
import { useState, useEffect, useCallback } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Memoize fetch function to prevent infinite re-renders
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, options);
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);
}
}, [url, options]); // Re-run if url or options change
// Trigger fetch on mount or when url/options change
useEffect(() => {
const controller = new AbortController();
options.signal = controller.signal; // Allow aborting on unmount
fetchData();
// Cleanup: abort fetch if component unmounts
return () => controller.abort();
}, [fetchData, options]);
return { data, loading, error, refetch: fetchData };
}
export default useFetch;
Usage in a Component:
// UserProfile.js
import useFetch from './useFetch';
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useFetch(
`https://api.example.com/users/${userId}`
);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error} <button onClick={refetch}>Retry</button></p>;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Example 2: useWindowSize – Track Window Dimensions
Tracking window width/height is useful for responsive UIs. useWindowSize uses useEffect to listen for resize events.
Hook Implementation:
// useWindowSize.js
import { useState, useEffect } from 'react';
function useWindowSize() {
// Initialize state with window dimensions (or defaults for SSR)
const [size, setSize] = useState({
width: typeof window !== 'undefined' ? window.innerWidth : 1200,
height: typeof window !== 'undefined' ? window.innerHeight : 800,
});
// Update size on window resize
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
// Add event listener
window.addEventListener('resize', handleResize);
// Initial check (in case window size changed before mount)
handleResize();
// Cleanup: remove event listener
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty deps: run once on mount
return size;
}
export default useWindowSize;
Usage in a Component:
// ResponsiveComponent.js
import useWindowSize from './useWindowSize';
function ResponsiveComponent() {
const { width, height } = useWindowSize();
return (
<div>
<p>Window Size: {width}px × {height}px</p>
{width < 768 && <p>Mobile view detected!</p>}
</div>
);
}
Advanced Tips for Custom Hooks
1. Optimize with useCallback/useMemo
If your custom hook returns functions (e.g., refetch in useFetch), wrap them in useCallback to avoid recreating the function on every render, which can trigger unnecessary re-renders in child components.
2. Compose Custom Hooks
Custom hooks can call other custom hooks to build complex logic. For example, useFormWithLocalStorage could use useForm (for validation) and useLocalStorage (for persistence):
function useFormWithLocalStorage(initialValues, validate) {
const [values, setValues] = useLocalStorage('formData', initialValues);
const { errors, isValid } = useFormValidation(values, validate);
return { values, setValues, errors, isValid };
}
3. Test Custom Hooks
Test custom hooks in isolation using @testing-library/react-hooks, which provides a renderHook utility to simulate hook usage:
// useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useLocalStorage from './useLocalStorage';
test('useLocalStorage initializes with localStorage value', () => {
// Mock localStorage
localStorage.setItem('testKey', JSON.stringify('testValue'));
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
expect(result.current[0]).toBe('testValue');
});
test('updates localStorage when state changes', () => {
const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
act(() => {
result.current[1]('newValue'); // Call setValue
});
expect(localStorage.getItem('testKey')).toBe(JSON.stringify('newValue'));
});
Common Pitfalls to Avoid
1. Forgetting the use Prefix
Names that don’t start with use (e.g., localStorageHook) won’t trigger React’s hook rule checks. This can lead to bugs (e.g., calling hooks in conditions). Always start with use!
2. Overcomplicating Hooks
Avoid building “kitchen-sink” hooks that do too much. A good custom hook should solve one specific problem (e.g., useFetch for data fetching, not data fetching + form validation).
3. Neglecting Cleanup
If your hook uses useEffect with side effects (e.g., event listeners, subscriptions), always return a cleanup function to prevent memory leaks:
// Bad: No cleanup
useEffect(() => {
window.addEventListener('resize', handleResize);
});
// Good: Cleanup
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
4. Assuming Hooks Share State
Each component using a custom hook gets its own isolated state. Two components using useLocalStorage('theme') will share the same localStorage key, but two components using useLocalStorage('theme1') and useLocalStorage('theme2') will have independent state.
Conclusion
Custom hooks are a powerful way to reuse stateful logic in React, improving code reusability, readability, and maintainability. By following the use naming convention, adhering to hook rules, and focusing on single responsibilities, you can build hooks that simplify your React applications.
The next time you find yourself copying stateful logic between components, ask: “Can this be a custom hook?” Chances are, the answer is yes!
References
- React Official Docs: Custom Hooks
- React Hook Rules
- @testing-library/react-hooks
- react-use (Popular custom hook library)
- useHooks.com (Collection of实用custom hooks)