React Custom Hooks Patterns
Introduction
Custom hooks are one of React's most powerful features, allowing developers to extract component logic into reusable functions. They enable you to share stateful logic between components without requiring complex patterns like higher-order components or render props.
In this tutorial, we'll explore various patterns for creating and using custom hooks in React applications. By the end, you'll have a solid understanding of how to implement custom hooks to solve common problems and improve your React codebase.
What Are Custom Hooks?
Custom hooks are JavaScript functions that start with the prefix use
and can call other hooks. This naming convention is important as it signals to both developers and React's linting rules that the function follows the rules of Hooks.
Let's start with a simple example:
import { useState, useEffect } from 'react';
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
return () => {
document.title = 'React App'; // Reset on unmount
};
}, [title]);
}
This custom hook changes the document title based on the provided value. We can use it in any component:
function ProfilePage({ username }) {
useDocumentTitle(`${username}'s Profile`);
return (
<div>
<h1>{username}'s Profile</h1>
{/* Rest of the component */}
</div>
);
}
Common Custom Hooks Patterns
Let's explore several patterns for creating useful custom hooks.
Pattern 1: State Management Hooks
These hooks encapsulate state logic to make it reusable across components.
Example: useToggle
Hook
import { useState, useCallback } from 'react';
function useToggle(initialState = false) {
const [state, setState] = useState(initialState);
const toggle = useCallback(() => {
setState(prevState => !prevState);
}, []);
return [state, toggle];
}
Usage:
function Accordion() {
const [isOpen, toggleOpen] = useToggle(false);
return (
<div>
<button onClick={toggleOpen}>
{isOpen ? 'Hide Content' : 'Show Content'}
</button>
{isOpen && (
<div className="content">
This content is toggleable!
</div>
)}
</div>
);
}
Pattern 2: Side Effect Hooks
These hooks manage side effects like API calls, subscriptions, or browser APIs.
Example: useFetch
Hook
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchData() {
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) {
if (err.name !== 'AbortError') {
setError(err.message);
setData(null);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => {
abortController.abort();
};
}, [url]);
return { data, loading, error };
}
Usage:
function UserProfile({ userId }) {
const { data, loading, error } = useFetch(`https://api.example.com/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="user-profile">
<h2>{data.name}</h2>
<p>Email: {data.email}</p>
{/* Other user details */}
</div>
);
}
Pattern 3: Reducer Hooks
For more complex state logic, custom hooks can leverage useReducer.
Example: useFormInput
Hook
import { useReducer } from 'react';
function formReducer(state, action) {
switch (action.type) {
case 'change':
return {
...state,
[action.field]: action.value
};
case 'reset':
return action.initialState;
default:
return state;
}
}
function useFormInput(initialState) {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({
type: 'change',
field,
value: e.target.value
});
};
const reset = () => {
dispatch({ type: 'reset', initialState });
};
return { state, handleChange, reset };
}
Usage:
function RegistrationForm() {
const { state, handleChange, reset } = useFormInput({
username: '',
email: '',
password: ''
});
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form submitted:', state);
// Submit form data
reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">Username:</label>
<input
id="username"
type="text"
value={state.username}
onChange={handleChange('username')}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={state.email}
onChange={handleChange('email')}
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={state.password}
onChange={handleChange('password')}
/>
</div>
<button type="submit">Register</button>
<button type="button" onClick={reset}>Reset</button>
</form>
);
}
Pattern 4: Composition of Hooks
Custom hooks can compose other hooks to create more complex behavior.
Example: useLocalStorage
Hook
import { useState, useEffect } from 'react';
function useLocalStorage(key, initialValue) {
// Get from local storage then parse stored json or return initialValue
const readValue = () => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
};
// State to store our value
const [storedValue, setStoredValue] = useState(readValue);
// Return a wrapped version of useState's setter function that persists the new value to localStorage.
const setValue = value => {
try {
// Allow value to be a function so we have the same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
};
useEffect(() => {
const handleStorageChange = () => {
setStoredValue(readValue());
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
return [storedValue, setValue];
}
Usage:
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return (
<div className={`app ${theme}`}>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'Dark' : 'Light'} Mode
</button>
<p>Current theme: {theme}</p>
</div>
);
}
Pattern 5: Context Provider Hooks
This pattern combines custom hooks with React Context to provide global state.
import { createContext, useContext, useState } from 'react';
// Create context
const AuthContext = createContext();
// Provider hook that wraps your app and makes auth object available to any child component that calls useAuth().
function useAuthProvider() {
const [user, setUser] = useState(null);
const login = (credentials) => {
// In a real app, validate credentials with an API
return new Promise((resolve) => {
setTimeout(() => {
setUser({ id: 1, name: 'John Doe', email: '[email protected]' });
resolve(true);
}, 1000);
});
};
const logout = () => {
setUser(null);
};
return {
user,
login,
logout,
isAuthenticated: !!user
};
}
// Provider component
export function AuthProvider({ children }) {
const auth = useAuthProvider();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
// Hook for child components to get the auth object and re-render when it changes
export function useAuth() {
return useContext(AuthContext);
}
Usage:
// App.js
import { AuthProvider } from './hooks/useAuth';
function App() {
return (
<AuthProvider>
<Router>
{/* Your app routes */}
<Navigation />
<Switch>
<Route exact path="/" component={Home} />
<Route path="/login" component={Login} />
<PrivateRoute path="/dashboard" component={Dashboard} />
</Switch>
</Router>
</AuthProvider>
);
}
// Navigation.js
import { useAuth } from './hooks/useAuth';
function Navigation() {
const { user, logout, isAuthenticated } = useAuth();
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
{isAuthenticated ? (
<>
<li><Link to="/dashboard">Dashboard</Link></li>
<li>
<span>Welcome, {user.name}</span>
<button onClick={logout}>Logout</button>
</li>
</>
) : (
<li><Link to="/login">Login</Link></li>
)}
</ul>
</nav>
);
}
Pattern 6: Event Handling Hooks
These hooks simplify event handling in React components.
Example: useClickOutside
Hook
import { useEffect, useRef } from 'react';
function useClickOutside(handler) {
const ref = useRef();
useEffect(() => {
const listener = (event) => {
// Do nothing if clicking ref's element or descendent elements
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
return ref;
}
Usage:
function Dropdown({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useClickOutside(() => {
if (isOpen) setIsOpen(false);
});
return (
<div className="dropdown" ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>
{title}
</button>
{isOpen && (
<div className="dropdown-content">
{children}
</div>
)}
</div>
);
}
Best Practices for Custom Hooks
When creating custom hooks, follow these best practices:
-
Follow the naming convention: Always prefix custom hook names with
use
to signal that they follow the rules of Hooks. -
Keep hooks focused: Each hook should handle a single responsibility or concern.
-
Handle cleanup properly: Ensure any subscriptions, timers, or event listeners are cleaned up to avoid memory leaks.
-
Use dependency arrays correctly: Make sure your
useEffect
anduseCallback
hooks have properly defined dependencies. -
Write tests: Custom hooks should be tested separately from components to verify their behavior.
-
Document your hooks: Include comments or documentation explaining the hook's purpose, parameters, and return values.
Real-world Example: Building a Complete Form System
Let's combine multiple hook patterns to build a comprehensive form system:
// hooks/useForm.js
import { useState, useEffect } from 'react';
function useForm({ initialValues, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
if (isSubmitting) {
const noErrors = Object.keys(errors).length === 0;
if (noErrors) {
onSubmit(values);
setIsSubmitting(false);
} else {
setIsSubmitting(false);
}
}
}, [errors, isSubmitting, values, onSubmit]);
const handleChange = (event) => {
const { name, value } = event.target;
setValues({ ...values, [name]: value });
};
const handleBlur = (event) => {
const { name } = event.target;
setTouched({ ...touched, [name]: true });
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
}
};
const handleSubmit = (event) => {
event.preventDefault();
// Mark all fields as touched
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
);
setTouched(allTouched);
if (validate) {
const validationErrors = validate(values);
setErrors(validationErrors);
setIsSubmitting(Object.keys(validationErrors).length === 0);
} else {
setIsSubmitting(true);
}
};
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
setValues
};
}
export default useForm;
Usage:
function SignupForm() {
const validate = (values) => {
const errors = {};
if (!values.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(values.email)) {
errors.email = 'Email address is invalid';
}
if (!values.password) {
errors.password = 'Password is required';
} else if (values.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (values.password !== values.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
return errors;
};
const handleSubmit = (values) => {
console.log('Form submitted with:', values);
// Submit to your API
alert('Signup successful!');
};
const {
values,
errors,
touched,
handleChange,
handleBlur,
handleSubmit,
} = useForm({
initialValues: {
email: '',
password: '',
confirmPassword: ''
},
validate,
onSubmit: handleSubmit
});
return (
<form onSubmit={handleSubmit}>
<h2>Sign Up</h2>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && (
<div className="error">{errors.email}</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && (
<div className="error">{errors.password}</div>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={values.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.confirmPassword && errors.confirmPassword && (
<div className="error">{errors.confirmPassword}</div>
)}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
Summary
Custom hooks are an essential pattern in modern React applications. They allow you to:
- Extract and reuse stateful logic between components
- Simplify complex components by extracting logic into smaller, focused hooks
- Share functionality across your application
- Create composition-based APIs rather than inheritance-based ones
- Write more testable code by isolating logic
By mastering custom hooks patterns, you'll write more maintainable, readable, and reusable React code. Remember to follow the patterns and best practices outlined in this tutorial to create hooks that provide value throughout your application.
Additional Resources
- React Docs: Building Your Own Hooks
- useHooks - A collection of ready-to-use custom hooks
- React Hooks Testing Library - For testing your custom hooks
Exercises
- Create a
useWindowSize
hook that tracks browser window dimensions. - Implement a
useDebounce
hook that delays the execution of a function. - Build a
usePagination
hook for handling pagination in lists. - Create a
useTheme
hook with Context API to provide theme switching capabilities. - Implement a
usePermission
hook that checks if a user has permission to access certain features.
Remember, custom hooks become more powerful when they're focused on solving specific problems in your application.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)