Introduction to Custom Hooks
React's built-in Hooks like useState, useEffect, and useReducer
provide powerful ways to add state and side effects to functional components. However, as applications
grow in complexity, you'll often find yourself reusing the same patterns of stateful logic across
multiple components.
Custom Hooks are a feature of React that allow you to extract component logic into reusable functions. They're a way to share stateful logic between components without changing their structure.
Real-world analogy: Think of custom hooks like recipes in a cookbook. Instead of repeating the same cooking steps (stateful logic) in every dish (component) you prepare, you can follow a standardized recipe (custom hook) that ensures consistent results and saves you time.
Why Use Custom Hooks?
- Reusability: Extract and reuse stateful logic across components
- Separation of concerns: Separate UI from complex logic
- Cleaner components: Move complex logic out of components
- Composition: Combine multiple hooks into more powerful ones
- Testability: Test logic independently from UI
- Community sharing: Share and use hooks across projects and teams
Custom Hook Rules and Conventions
When creating custom hooks, follow these important rules and conventions:
Naming Convention
Custom hooks must start with "use" (e.g., useFormInput, useFetch).
This naming convention is critical because:
- It signals to React that this function follows the rules of Hooks
- It tells developers that this function is a Hook, not a regular function
- It allows React's linter plugin to apply the rules of Hooks
Hooks Rules Still Apply
Custom hooks must follow the same rules as built-in hooks:
- Only call hooks at the top level (not inside loops, conditions, or nested functions)
- Only call hooks from React function components or other custom hooks
Return Values
Custom hooks can return anything:
- A single value or an array of values
- An object with named properties
- Functions to be called by the component
Hook Composition
Custom hooks can use other hooks, both built-in and custom:
function useWindowSize() {
// Using built-in hooks
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// ...
}, []);
return size;
}
function useResponsiveLayout() {
// Using another custom hook
const windowSize = useWindowSize();
// Additional logic...
const isMobile = windowSize.width < 768;
return { ...windowSize, isMobile };
}
Building Your First Custom Hook
Let's start with a simple example: a useToggle hook that manages a boolean state.
The Problem
Setting up toggle state is a common pattern:
// Without custom hook
function Accordion() {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => {
setIsOpen(prevIsOpen => !prevIsOpen);
};
return (
<div>
<button onClick={toggle}>
{isOpen ? 'Close' : 'Open'} Accordion
</button>
{isOpen && (
<div className="accordion-content">
Content goes here...
</div>
)}
</div>
);
}
The Solution: useToggle Hook
// Custom hook definition
function useToggle(initialState = false) {
const [state, setState] = useState(initialState);
// Define and memoize toggler function
const toggle = useCallback(() => {
setState(state => !state);
}, []);
return [state, toggle];
}
// Using the custom hook
function Accordion() {
const [isOpen, toggle] = useToggle(false);
return (
<div>
<button onClick={toggle}>
{isOpen ? 'Close' : 'Open'} Accordion
</button>
{isOpen && (
<div className="accordion-content">
Content goes here...
</div>
)}
</div>
);
}
Benefits of this approach:
- Cleaner component code
- Reusable across many components (modals, dropdowns, expandable sections, etc.)
- Consistent implementation
- The toggle logic can be extended without changing every component
Enhanced Version
We can enhance the useToggle hook to provide more functionality:
function useToggle(initialState = false) {
const [state, setState] = useState(initialState);
const toggle = useCallback(() => {
setState(state => !state);
}, []);
const setTrue = useCallback(() => {
setState(true);
}, []);
const setFalse = useCallback(() => {
setState(false);
}, []);
// Return state and all functions
return [state, toggle, setTrue, setFalse];
}
function Modal() {
const [isOpen, toggle, openModal, closeModal] = useToggle(false);
return (
<div>
<button onClick={openModal}>Open Modal</button>
{isOpen && (
<div className="modal">
<div className="modal-content">
<h2>Modal Title</h2>
<p>Modal content goes here...</p>
<button onClick={closeModal}>Close</button>
</div>
</div>
)}
</div>
);
}
Custom Hooks for Form Handling
Forms are a perfect candidate for custom hooks because they involve repetitive logic for managing input values, validation, and submission.
1. Basic Form Input Hook
function useInput(initialValue = '') {
const [value, setValue] = useState(initialValue);
const handleChange = useCallback((e) => {
setValue(e.target.value);
}, []);
const reset = useCallback(() => {
setValue(initialValue);
}, [initialValue]);
// Return everything needed by the input
return {
value,
onChange: handleChange,
reset,
// Add useful props to make it easier to spread onto inputs
inputProps: {
value,
onChange: handleChange
}
};
}
// Usage example
function SimpleForm() {
const name = useInput('');
const email = useInput('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitted:', { name: name.value, email: email.value });
name.reset();
email.reset();
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input type="text" {...name.inputProps} />
</div>
<div>
<label>Email:</label>
<input type="email" {...email.inputProps} />
</div>
<button type="submit">Submit</button>
</form>
);
}
2. Form Validation Hook
function useFormValidation(initialValues, validate) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Update field value
const handleChange = useCallback((e) => {
const { name, value } = e.target;
setValues(prevValues => ({
...prevValues,
[name]: value
}));
}, []);
// Mark field as touched on blur
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(prevTouched => ({
...prevTouched,
[name]: true
}));
}, []);
// Validate all fields
const validateForm = useCallback(() => {
const validationErrors = validate(values);
setErrors(validationErrors);
return Object.keys(validationErrors).length === 0;
}, [values, validate]);
// Reset form
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
// Handle form submission
const handleSubmit = useCallback(async (e, onSubmit) => {
e.preventDefault();
setTouched(
Object.keys(values).reduce((touched, field) => {
touched[field] = true;
return touched;
}, {})
);
const isValid = validateForm();
if (isValid) {
setIsSubmitting(true);
try {
await onSubmit(values);
reset();
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
}
}, [values, validateForm, reset]);
// Validate on touched fields change
useEffect(() => {
if (Object.keys(touched).length > 0) {
validateForm();
}
}, [touched, validateForm]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset
};
}
// Usage example
function SignupForm() {
// Validation function
const validate = (values) => {
const errors = {};
if (!values.username) {
errors.username = 'Username is required';
} else if (values.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
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';
}
return errors;
};
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset
} = useFormValidation(
{ username: '', email: '', password: '' },
validate
);
const onSubmit = async (values) => {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted successfully:', values);
alert('Signup successful!');
};
return (
<form onSubmit={(e) => handleSubmit(e, onSubmit)}>
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
id="username"
name="username"
type="text"
value={values.username}
onChange={handleChange}
onBlur={handleBlur}
className={touched.username && errors.username ? 'error' : ''}
/>
{touched.username && errors.username && (
<div className="error-message">{errors.username}</div>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
className={touched.email && errors.email ? 'error' : ''}
/>
{touched.email && errors.email && (
<div className="error-message">{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}
className={touched.password && errors.password ? 'error' : ''}
/>
{touched.password && errors.password && (
<div className="error-message">{errors.password}</div>
)}
</div>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Signing Up...' : 'Sign Up'}
</button>
<button type="button" onClick={reset}>Reset</button>
</div>
</form>
);
}
Custom Hooks for Data Fetching
Data fetching is another common pattern that can benefit from custom hooks.
Basic Data Fetching Hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset state when URL changes
setLoading(true);
setError(null);
setData(null);
let isMounted = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
if (isMounted) {
setData(result);
}
} catch (error) {
if (isMounted) {
setError(error.message || 'Something went wrong');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
fetchData();
// Cleanup function
return () => {
isMounted = false;
};
}, [url]);
return { data, loading, error };
}
// Usage example
function UserProfile({ 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>;
if (!user) return <div>No user found</div>;
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Website: {user.website}</p>
</div>
);
}
Advanced Data Fetching Hook with Cache and Refresh
function useDataApi(initialUrl, initialData) {
const [url, setUrl] = useState(initialUrl);
const [state, dispatch] = useReducer(dataFetchReducer, {
data: initialData,
loading: true,
error: null,
timestamp: null
});
// Cache for storing previous results
const cache = useRef({});
// Define reducer function
function dataFetchReducer(state, action) {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
loading: true,
error: null
};
case 'FETCH_SUCCESS':
return {
...state,
loading: false,
data: action.payload,
error: null,
timestamp: new Date()
};
case 'FETCH_FAILURE':
return {
...state,
loading: false,
error: action.payload
};
default:
throw new Error(`Unsupported action type: ${action.type}`);
}
}
// Function to fetch data
const fetchData = useCallback(async (url, ignoreCache = false) => {
dispatch({ type: 'FETCH_INIT' });
// Check cache first if not ignoring cache
if (!ignoreCache && cache.current[url]) {
dispatch({ type: 'FETCH_SUCCESS', payload: cache.current[url] });
return;
}
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Store in cache
cache.current[url] = data;
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_FAILURE', payload: error.message });
}
}, []);
// Fetch data when URL changes
useEffect(() => {
fetchData(url);
}, [url, fetchData]);
// Function to refresh data
const refresh = useCallback(() => {
fetchData(url, true);
}, [url, fetchData]);
// Function to change URL and fetch new data
const setUrlAndFetch = useCallback((newUrl) => {
setUrl(newUrl);
}, []);
return {
...state,
refresh,
setUrl: setUrlAndFetch
};
}
// Usage example
function PostList() {
const [postId, setPostId] = useState(1);
const {
data,
loading,
error,
timestamp,
refresh,
setUrl
} = useDataApi(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
{}
);
const handleNextPost = () => {
setPostId(prevId => prevId + 1);
setUrl(`https://jsonplaceholder.typicode.com/posts/${postId + 1}`);
};
return (
<div>
{loading ? (
<div>Loading...</div>
) : error ? (
<div>Error: {error}</div>
) : (
<div>
<h2>{data.title}</h2>
<p>{data.body}</p>
{timestamp && (
<small>Last fetched: {timestamp.toLocaleTimeString()}</small>
)}
</div>
)}
<div className="controls">
<button onClick={handleNextPost}>Next Post</button>
<button onClick={refresh}>Refresh</button>
</div>
</div>
);
}
Custom Hooks for Browser APIs
Custom hooks are perfect for encapsulating browser APIs and making them reactive.
1. Window Size Hook
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
// Handler to call on window resize
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Call handler right away to update initial size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures effect runs only on mount and unmount
return windowSize;
}
// Usage example
function ResponsiveComponent() {
const { width, height } = useWindowSize();
const isMobile = width < 768;
return (
<div>
<p>Window size: {width} x {height}</p>
<p>You are viewing this on a {isMobile ? 'mobile' : 'desktop'} device.</p>
{isMobile ? (
<MobileLayout />
) : (
<DesktopLayout />
)}
</div>
);
}
2. Clipboard Hook
function useClipboard() {
const [copiedText, setCopiedText] = useState(null);
const [error, setError] = useState(null);
const copy = useCallback(async (text) => {
try {
await navigator.clipboard.writeText(text);
setCopiedText(text);
setError(null);
// Reset copied text after 2 seconds
setTimeout(() => {
setCopiedText(null);
}, 2000);
return true;
} catch (err) {
console.error('Failed to copy:', err);
setError('Failed to copy text');
return false;
}
}, []);
return { copiedText, error, copy };
}
// Usage example
function CopyableText({ text }) {
const { copiedText, copy } = useClipboard();
const handleCopy = () => {
copy(text);
};
return (
<div className="copyable-text">
<pre>{text}</pre>
<button onClick={handleCopy}>
{copiedText === text ? 'Copied!' : 'Copy'}
</button>
</div>
);
}
3. Geolocation Hook
function useGeolocation(options = {}) {
const [location, setLocation] = useState({
loaded: false,
coordinates: { lat: null, lng: null },
error: null
});
useEffect(() => {
if (!navigator.geolocation) {
setLocation({
loaded: true,
error: {
code: 0,
message: 'Geolocation not supported'
}
});
return;
}
const onSuccess = (position) => {
const { latitude, longitude } = position.coords;
setLocation({
loaded: true,
coordinates: {
lat: latitude,
lng: longitude
},
error: null
});
};
const onError = (error) => {
setLocation({
loaded: true,
coordinates: {
lat: null,
lng: null
},
error
});
};
navigator.geolocation.getCurrentPosition(
onSuccess,
onError,
options
);
// If you need continuous updates, you can use watchPosition
// const watchId = navigator.geolocation.watchPosition(onSuccess, onError, options);
// return () => navigator.geolocation.clearWatch(watchId);
}, [options]);
return location;
}
// Usage example
function LocationBasedComponent() {
const { loaded, coordinates, error } = useGeolocation({
enableHighAccuracy: true,
maximumAge: 15000,
timeout: 12000
});
if (!loaded) return <div>Getting your location...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>Your Location</h2>
<p>Latitude: {coordinates.lat}</p>
<p>Longitude: {coordinates.lng}</p>
<a
href={`https://maps.google.com/?q=${coordinates.lat},${coordinates.lng}`}
target="_blank"
rel="noopener noreferrer"
>
View on Google Maps
</a>
</div>
);
}
Custom Hooks for UI Interactions
These hooks can help manage common UI interaction patterns.
1. Hover Hook
function useHover() {
const [isHovering, setIsHovering] = useState(false);
const ref = useRef(null);
const handleMouseOver = useCallback(() => setIsHovering(true), []);
const handleMouseOut = useCallback(() => setIsHovering(false), []);
useEffect(() => {
const node = ref.current;
if (node) {
node.addEventListener('mouseover', handleMouseOver);
node.addEventListener('mouseout', handleMouseOut);
return () => {
node.removeEventListener('mouseover', handleMouseOver);
node.removeEventListener('mouseout', handleMouseOut);
};
}
}, [handleMouseOver, handleMouseOut]);
// Return both the hover state and the ref to be attached
return [ref, isHovering];
}
// Usage example
function HoverCard() {
const [hoverRef, isHovering] = useHover();
return (
<div
ref={hoverRef}
className={`card ${isHovering ? 'card-hover' : ''}`}
>
<h3>Hover Me</h3>
{isHovering && (
<div className="card-tooltip">
This card is being hovered!
</div>
)}
</div>
);
}
2. Drag and Drop Hook
function useDrag(initialPosition = { x: 0, y: 0 }) {
const [position, setPosition] = useState(initialPosition);
const [isDragging, setIsDragging] = useState(false);
const elementRef = useRef(null);
// Store the starting position and offset
const dragInfo = useRef({
startX: 0,
startY: 0,
initialX: 0,
initialY: 0
});
const handleMouseDown = useCallback((e) => {
if (!elementRef.current) return;
// Capture starting position
dragInfo.current.startX = e.clientX;
dragInfo.current.startY = e.clientY;
dragInfo.current.initialX = position.x;
dragInfo.current.initialY = position.y;
setIsDragging(true);
// Prevent text selection during drag
document.body.style.userSelect = 'none';
}, [position]);
const handleMouseMove = useCallback((e) => {
if (!isDragging) return;
// Calculate new position
const dx = e.clientX - dragInfo.current.startX;
const dy = e.clientY - dragInfo.current.startY;
setPosition({
x: dragInfo.current.initialX + dx,
y: dragInfo.current.initialY + dy
});
}, [isDragging]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
document.body.style.userSelect = '';
}, []);
useEffect(() => {
// Add event listeners when dragging starts
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
// Clean up event listeners
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, handleMouseMove, handleMouseUp]);
return {
position,
isDragging,
elementRef,
handleMouseDown
};
}
// Usage example
function DraggableBox() {
const { position, isDragging, elementRef, handleMouseDown } = useDrag();
return (
<div
ref={elementRef}
style={{
width: '100px',
height: '100px',
backgroundColor: isDragging ? '#3498db' : '#e74c3c',
position: 'absolute',
left: `${position.x}px`,
top: `${position.y}px`,
cursor: isDragging ? 'grabbing' : 'grab',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
userSelect: 'none'
}}
onMouseDown={handleMouseDown}
>
Drag Me
</div>
);
}
3. Infinite Scroll Hook
function useInfiniteScroll(callback, options = {}) {
const { threshold = 100, initialLoad = true } = options;
const [isFetching, setIsFetching] = useState(initialLoad);
const [isEnd, setIsEnd] = useState(false);
// Function to call when bottom is reached
const fetchMoreItems = useCallback(() => {
if (!isFetching && !isEnd) {
setIsFetching(true);
}
}, [isFetching, isEnd]);
// Check if we've reached the bottom
const handleScroll = useCallback(() => {
if (isEnd) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
// If we're near the bottom, fetch more items
if (scrollHeight - scrollTop - clientHeight < threshold) {
fetchMoreItems();
}
}, [fetchMoreItems, isEnd, threshold]);
// Call the callback when isFetching changes
useEffect(() => {
if (!isFetching) return;
const fetchData = async () => {
try {
// Call the callback and get result
const hasMore = await callback();
// If there's no more data, set isEnd to true
if (hasMore === false) {
setIsEnd(true);
}
} catch (error) {
console.error('Error fetching more items:', error);
} finally {
setIsFetching(false);
}
};
fetchData();
}, [callback, isFetching]);
// Add scroll event listener
useEffect(() => {
window.addEventListener('scroll', handleScroll);
// Initial load if required
if (initialLoad) {
fetchMoreItems();
}
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll, fetchMoreItems, initialLoad]);
return { isFetching, isEnd, setIsEnd };
}
// Usage example
function InfinitePostList() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const loadMorePosts = useCallback(async () => {
try {
// Simulate API call
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
);
const newPosts = await response.json();
if (newPosts.length === 0) {
return false; // No more posts
}
setPosts(prevPosts => [...prevPosts, ...newPosts]);
setPage(prevPage => prevPage + 1);
return true; // More posts might be available
} catch (error) {
console.error('Error loading posts:', error);
return false;
}
}, [page]);
const { isFetching, isEnd } = useInfiniteScroll(loadMorePosts);
return (
<div className="post-list">
<h2>Posts</h2>
{posts.map(post => (
<div key={post.id} className="post">
<h3>{post.title}</h3>
<p>{post.body}</p>
</div>
))}
{isFetching && <div className="loading">Loading more posts...</div>}
{isEnd && <div className="end-message">No more posts to load</div>}
</div>
);
}
Composing Custom Hooks
One of the most powerful features of custom hooks is composition - you can build complex hooks by combining simpler ones.
Building a Complex Hook: useLocalStorageState
// First, create a hook for localStorage interaction
function useLocalStorage(key, initialValue) {
// Get stored value from localStorage
const getStoredValue = () => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
};
// Store a reference to the key
const keyRef = useRef(key);
// State to store the value
const [storedValue, setStoredValue] = useState(getStoredValue);
// Update keyRef if key changes
useEffect(() => {
keyRef.current = key;
}, [key]);
// Function to update stored value
const setValue = useCallback((value) => {
try {
// Allow value to be a function
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to localStorage
window.localStorage.setItem(keyRef.current, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${keyRef.current}":`, error);
}
}, [storedValue]);
// Listen for changes in other tabs/windows
useEffect(() => {
function handleStorageChange(event) {
if (event.key === keyRef.current) {
setStoredValue(getStoredValue());
}
}
// Listen for storage event
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
}, []);
return [storedValue, setValue];
}
// Now use the above hook to create a theme hook
function useTheme() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const toggleTheme = useCallback(() => {
setTheme(currentTheme => currentTheme === 'light' ? 'dark' : 'light');
}, [setTheme]);
// Apply theme to document when it changes
useEffect(() => {
document.body.setAttribute('data-theme', theme);
}, [theme]);
return { theme, setTheme, toggleTheme, isDarkTheme: theme === 'dark' };
}
// Create a hook for user settings that uses the theme hook
function useUserSettings() {
const { theme, setTheme, toggleTheme, isDarkTheme } = useTheme();
const [fontSize, setFontSize] = useLocalStorage('fontSize', 'medium');
const [notifications, setNotifications] = useLocalStorage('notifications', true);
// Create preset configurations
const applyPreset = useCallback((preset) => {
switch (preset) {
case 'reading':
setTheme('light');
setFontSize('large');
break;
case 'night':
setTheme('dark');
setFontSize('medium');
break;
case 'default':
default:
setTheme('light');
setFontSize('medium');
break;
}
}, [setTheme, setFontSize]);
return {
theme,
setTheme,
toggleTheme,
isDarkTheme,
fontSize,
setFontSize,
notifications,
setNotifications,
applyPreset
};
}
// Usage example
function UserSettingsPanel() {
const {
theme,
toggleTheme,
fontSize,
setFontSize,
notifications,
setNotifications,
applyPreset
} = useUserSettings();
return (
<div className="settings-panel">
<h2>User Settings</h2>
<div className="setting-group">
<h3>Theme</h3>
<button onClick={toggleTheme}>
{theme === 'light' ? 'Switch to Dark Mode' : 'Switch to Light Mode'}
</button>
</div>
<div className="setting-group">
<h3>Font Size</h3>
<select
value={fontSize}
onChange={(e) => setFontSize(e.target.value)}
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
<div className="setting-group">
<h3>Notifications</h3>
<label>
<input
type="checkbox"
checked={notifications}
onChange={(e) => setNotifications(e.target.checked)}
/>
Enable Notifications
</label>
</div>
<div className="setting-presets">
<h3>Presets</h3>
<button onClick={() => applyPreset('default')}>Default</button>
<button onClick={() => applyPreset('reading')}>Reading Mode</button>
<button onClick={() => applyPreset('night')}>Night Mode</button>
</div>
</div>
);
}
Testing Custom Hooks
Testing custom hooks requires some special considerations since hooks can only be called inside function components.
Using React Testing Library's renderHook
// Example test for useCounter hook
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
describe('useCounter', () => {
test('should initialize with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('should initialize with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('should decrement counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('should reset counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
test('should reset counter to custom value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.reset(10);
});
expect(result.current.count).toBe(10);
});
test('should update when dependencies change', () => {
const { result, rerender } = renderHook(
({ initialValue }) => useCounter(initialValue),
{ initialProps: { initialValue: 0 } }
);
// Change the initial value prop
rerender({ initialValue: 10 });
// Check if hook updates with new initialValue
expect(result.current.count).toBe(10);
});
});
Creating a Test Component Wrapper
For testing hooks that use context or other React features, you might need to create a wrapper component:
// Testing a hook that uses context
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import useTheme from './useTheme';
// Test component that uses the hook
function TestComponent() {
const { theme, toggleTheme } = useTheme();
return (
<div>
<div data-testid="theme-value">{theme}</div>
<button onClick={toggleTheme} data-testid="toggle-button">
Toggle Theme
</button>
</div>
);
}
describe('useTheme', () => {
test('should have initial theme set to light', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme-value').textContent).toBe('light');
});
test('should toggle theme when button is clicked', () => {
render(
<ThemeProvider>
<TestComponent />
</ThemeProvider>
);
// Initial state
expect(screen.getByTestId('theme-value').textContent).toBe('light');
// Click toggle button
fireEvent.click(screen.getByTestId('toggle-button'));
// Check updated state
expect(screen.getByTestId('theme-value').textContent).toBe('dark');
});
});
Remember to mock any external dependencies like browser APIs or fetch calls when testing hooks that use them.
Best Practices for Custom Hooks
Naming Conventions
- Always start hook names with 'use' (e.g.,
useFormInput,useFetch) - Use descriptive names that indicate what the hook does
- For hooks that mirror DOM events, use the 'use' prefix with the event name (e.g.,
useMouseMove,useScroll)
Designing Hook APIs
- Keep hooks focused on a single responsibility
- Accept configuration options as parameters
- Return only what's necessary for components
- Consider providing convenience props for common use cases
- Use consistent return patterns (objects vs. arrays)
Performance Considerations
- Memoize expensive calculations with
useMemo - Memoize callback functions with
useCallback - Be careful with dependency arrays to avoid unnecessary re-renders
- Avoid creating new objects or functions on every render
- Consider the effect of hook composition on performance
Error Handling
- Include proper error states and error handling in hooks
- Provide meaningful error messages
- Document potential errors and how to handle them
- Consider providing error recovery mechanisms
Documentation
- Clearly document parameters, return values, and behavior
- Include examples of common use cases
- Document any side effects or cleanup behavior
- Note any browser compatibility concerns
Practice Exercises
Exercise 1: Media Query Hook
Create a useMediaQuery hook that monitors a CSS media query and returns
a boolean indicating whether it matches.
// Example usage:
function ResponsiveComponent() {
const isDesktop = useMediaQuery('(min-width: 1024px)');
const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
const isMobile = useMediaQuery('(max-width: 767px)');
return (
<div>
{isDesktop && <DesktopLayout />}
{isTablet && <TabletLayout />}
{isMobile && <MobileLayout />}
</div>
);
}
Exercise 2: Form Field Hook
Create a useFormField hook that handles validation, error messages,
formatting, and other common form field behaviors.
// Example usage:
function RegistrationForm() {
const username = useFormField('', {
required: 'Username is required',
minLength: { value: 3, message: 'Username must be at least 3 characters' },
pattern: {
value: /^[A-Za-z0-9_]+$/,
message: 'Username can only contain letters, numbers and underscores'
}
});
const email = useFormField('', {
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Email address is invalid'
}
});
// More form fields...
return (
<form>
<div>
<label htmlFor="username">Username:</label>
<input id="username" {...username.props} />
{username.error && (
<span className="error">{username.error}</span>
)}
</div>
<div>
<label htmlFor="email">Email:</label>
<input id="email" type="email" {...email.props} />
{email.error && (
<span className="error">{email.error}</span>
)}
</div>
{/* More form fields... */}
</form>
);
}
Exercise 3: Combined Hooks Application
Create a small application that combines at least three different custom hooks. For example, a todo list app that uses:
useLocalStorageto persist todosuseSortedListto sort todosuseFilterto filter todos
Design each hook to be reusable and combine them effectively.
Summary
In this lecture, we've covered:
- What custom hooks are and why they're useful
- The rules and conventions for creating custom hooks
- Building basic custom hooks for toggle state and form inputs
- Advanced hook patterns for form validation and data fetching
- Hooks for browser APIs and UI interactions
- Composing hooks to create more powerful abstractions
- Testing custom hooks
- Best practices for designing and using custom hooks
Custom hooks are a powerful pattern in React that allow you to extract and reuse stateful logic across components. By following the conventions and best practices we've discussed, you can create a library of hooks that will make your React applications more maintainable, readable, and efficient.
As you build more complex React applications, look for opportunities to abstract common patterns into custom hooks. This will not only reduce duplication in your codebase, but also make your components cleaner and more focused on their primary purpose of rendering UI.