Welcome to Custom Hooks!
Imagine you're a chef creating a signature sauce. Once you perfect the recipe, you can use it in multiple dishes without remaking it each time. Custom hooks are like your secret sauce recipes in React - they let you extract and reuse component logic across your entire application!
graph TD
A[Component Logic] --> B[Extract into Hook]
B --> C[Reusable Custom Hook]
C --> D[Component 1]
C --> E[Component 2]
C --> F[Component 3]
style A fill:#ff9999
style C fill:#99ff99
style B fill:#9999ff
What Are Custom Hooks?
Custom hooks are JavaScript functions that:
- Start with "use" (e.g., useAuth, useLocalStorage)
- Can call other hooks
- Extract component logic into reusable functions
- Share stateful logic between components
Your First Custom Hook
// Without custom hook - repetitive code
function ProductList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* render products */}</div>;
}
// With custom hook - clean and reusable
function useFetch(url) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [url]);
return { loading, error, data };
}
// Now it's super clean!
function ProductList() {
const { loading, error, data } = useFetch('/api/products');
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* render products */}</div>;
}
Common Custom Hook Patterns
1. Form Handling Hook
function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({
...prev,
[name]: value
}));
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({
...prev,
[name]: true
}));
};
const validateField = (name, value) => {
// Add your validation logic here
if (!value) {
return 'This field is required';
}
return '';
};
const handleSubmit = (onSubmit) => async (e) => {
e.preventDefault();
setIsSubmitting(true);
// Validate all fields
const newErrors = {};
Object.keys(values).forEach(key => {
const error = validateField(key, values[key]);
if (error) newErrors[key] = error;
});
if (Object.keys(newErrors).length === 0) {
await onSubmit(values);
} else {
setErrors(newErrors);
}
setIsSubmitting(false);
};
const resetForm = () => {
setValues(initialValues);
setErrors({});
setTouched({});
setIsSubmitting(false);
};
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
resetForm
};
}
// Usage
function LoginForm() {
const {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit
} = useForm({ email: '', password: '' });
const onSubmit = async (values) => {
await loginAPI(values);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.email && errors.email && <span>{errors.email}</span>}
</div>
<div>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
/>
{touched.password && errors.password && <span>{errors.password}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in...' : 'Login'}
</button>
</form>
);
}
2. Local Storage Hook
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 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);
}
};
return [storedValue, setValue];
}
// Usage
function DarkModeToggle() {
const [isDarkMode, setIsDarkMode] = useLocalStorage('darkMode', false);
return (
<button onClick={() => setIsDarkMode(prev => !prev)}>
{isDarkMode ? '🌞 Light Mode' : '🌙 Dark Mode'}
</button>
);
}
3. Window Size Hook
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
function handleResize() {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener('resize', handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty array ensures effect only runs on mount
return windowSize;
}
// Usage
function ResponsiveComponent() {
const { width } = useWindowSize();
return (
<div>
{width < 768 ? (
<MobileView />
) : (
<DesktopView />
)}
</div>
);
}
4. Debounce Hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
// Set debouncedValue to value after the specified delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on component unmount)
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
// Fetch results using the debounced search term
useEffect(() => {
if (debouncedSearchTerm) {
// API call here
searchAPI(debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
}
Advanced Custom Hook Patterns
1. Async Data Hook with Caching
const cache = new Map();
function useAsyncData(key, fetchFn, options = {}) {
const {
cacheTime = 5 * 60 * 1000, // 5 minutes default
refetchOnMount = false,
refetchInterval = null
} = options;
const [state, setState] = useState({
data: cache.get(key)?.data ?? null,
loading: !cache.has(key),
error: null,
lastUpdated: cache.get(key)?.timestamp ?? null
});
const fetchData = async () => {
setState(prev => ({ ...prev, loading: true }));
try {
const data = await fetchFn();
const timestamp = Date.now();
cache.set(key, { data, timestamp });
setState({
data,
loading: false,
error: null,
lastUpdated: timestamp
});
} catch (error) {
setState(prev => ({
...prev,
loading: false,
error
}));
}
};
useEffect(() => {
const cachedData = cache.get(key);
const now = Date.now();
if (!cachedData ||
(cachedData && now - cachedData.timestamp > cacheTime) ||
refetchOnMount) {
fetchData();
}
}, [key]);
useEffect(() => {
if (refetchInterval) {
const interval = setInterval(fetchData, refetchInterval);
return () => clearInterval(interval);
}
}, [refetchInterval]);
const refetch = () => {
cache.delete(key);
fetchData();
};
return { ...state, refetch };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useAsyncData(
`user-${userId}`,
() => fetchUser(userId),
{ cacheTime: 10 * 60 * 1000 } // 10 minutes
);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h1>{user.name}</h1>
<button onClick={refetch}>Refresh</button>
</div>
);
}
2. Media Query Hook
function useMediaQuery(query) {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
if (media.matches !== matches) {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
// Modern browsers
if (media.addEventListener) {
media.addEventListener('change', listener);
} else {
// Fallback for older browsers
media.addListener(listener);
}
return () => {
if (media.removeEventListener) {
media.removeEventListener('change', listener);
} else {
media.removeListener(listener);
}
};
}, [matches, query]);
return matches;
}
// Convenience hooks
function useBreakpoint() {
const isMobile = useMediaQuery('(max-width: 640px)');
const isTablet = useMediaQuery('(min-width: 641px) and (max-width: 1024px)');
const isDesktop = useMediaQuery('(min-width: 1025px)');
return { isMobile, isTablet, isDesktop };
}
// Usage
function ResponsiveLayout() {
const { isMobile, isTablet, isDesktop } = useBreakpoint();
return (
<div>
{isMobile && <MobileLayout />}
{isTablet && <TabletLayout />}
{isDesktop && <DesktopLayout />}
</div>
);
}
3. Event Listener Hook
function useEventListener(eventName, handler, element = window) {
// Create a ref that stores handler
const savedHandler = useRef();
// Update ref.current value if handler changes
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// Make sure element supports addEventListener
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// Create event listener that calls handler function stored in ref
const eventListener = (event) => savedHandler.current(event);
// Add event listener
element.addEventListener(eventName, eventListener);
// Remove event listener on cleanup
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]);
}
// Usage: Click outside detector
function useClickOutside(ref, handler) {
useEventListener('mousedown', (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
});
}
function Dropdown() {
const dropdownRef = useRef();
const [isOpen, setIsOpen] = useState(false);
useClickOutside(dropdownRef, () => setIsOpen(false));
return (
<div ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>
Toggle Dropdown
</button>
{isOpen && (
<div className="dropdown-menu">
{/* Dropdown content */}
</div>
)}
</div>
);
}
Composing Custom Hooks
One of the most powerful features of custom hooks is that they can use other hooks, including other custom hooks!
// Compose multiple hooks for authentication
function useAuth() {
const [user, setUser] = useLocalStorage('user', null);
const [token, setToken] = useLocalStorage('token', null);
const { data: userData, loading, error, refetch } = useAsyncData(
'current-user',
() => fetchCurrentUser(token),
{ enabled: !!token }
);
const login = async (email, password) => {
try {
const response = await authAPI.login(email, password);
setToken(response.token);
setUser(response.user);
return response;
} catch (error) {
throw error;
}
};
const logout = () => {
setToken(null);
setUser(null);
};
const isAuthenticated = !!token && !!user;
return {
user: userData || user,
loading,
error,
isAuthenticated,
login,
logout,
refetch
};
}
// Usage
function App() {
const { user, isAuthenticated, loading } = useAuth();
if (loading) return <LoadingScreen />;
return (
<Router>
{isAuthenticated ? <AuthenticatedApp /> : <PublicApp />}
</Router>
);
}
Testing Custom Hooks
Custom hooks need to be tested to ensure they work correctly. Here's how to test them:
// useCounter.js
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';
describe('useCounter', () => {
it('should initialize with the correct value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('should increment counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('should decrement counter', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
it('should reset to initial value', () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
Best Practices for Custom Hooks
1. Always Start with "use"
// ✅ Good
function useUserData() { ... }
function useAuth() { ... }
function useLocalStorage() { ... }
// ❌ Bad
function getUserData() { ... }
function authHook() { ... }
function storageManager() { ... }
2. Keep Hooks Pure
// ✅ Good - pure function
function usePagination(items, itemsPerPage) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(items.length / itemsPerPage);
const currentItems = items.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
return {
currentItems,
currentPage,
totalPages,
setCurrentPage
};
}
// ❌ Bad - side effects outside of useEffect
function usePagination(items, itemsPerPage) {
const [currentPage, setCurrentPage] = useState(1);
// Side effect! This will run on every render
document.title = `Page ${currentPage}`;
// Rest of the logic...
}
3. Return Consistent Values
// ✅ Good - consistent return structure
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Always return the same structure
return { user, loading, error };
}
// ❌ Bad - inconsistent returns
function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
if (loading) return { loading };
if (user) return { user };
return null;
}
Practice Exercises
Exercise 1: Create a Countdown Timer Hook
Create a useCountdown hook that:
- Accepts initial time in seconds
- Provides start, pause, reset functions
- Returns remaining time
- Calls a callback when time reaches zero
// Your implementation
function useCountdown(initialTime, onComplete) {
// Implement the countdown logic here
return {
timeLeft,
isRunning,
start,
pause,
reset
};
}
Exercise 2: Create an Intersection Observer Hook
Build a hook for lazy loading images or infinite scroll:
// Create useIntersectionObserver hook
function useIntersectionObserver(options = {}) {
// Implement intersection observer logic
// Return ref and whether element is visible
}
Exercise 3: Create a WebSocket Hook
Build a hook for WebSocket connections:
- Connect/disconnect functionality
- Send messages
- Receive messages
- Handle connection status
Real-World Custom Hook Examples
1. Infinite Scroll Hook
function useInfiniteScroll(fetchMore) {
const [isFetching, setIsFetching] = useState(false);
const observer = useRef();
const lastElementRef = useCallback(node => {
if (isFetching) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
setIsFetching(true);
fetchMore().finally(() => setIsFetching(false));
}
});
if (node) observer.current.observe(node);
}, [isFetching, fetchMore]);
return [lastElementRef, isFetching];
}
// Usage
function PostList() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const fetchMorePosts = async () => {
const newPosts = await fetchPosts(page);
setPosts(prev => [...prev, ...newPosts]);
setPage(prev => prev + 1);
};
const [lastPostRef, isFetching] = useInfiniteScroll(fetchMorePosts);
return (
<div>
{posts.map((post, index) => (
<div
key={post.id}
ref={index === posts.length - 1 ? lastPostRef : null}
>
<Post data={post} />
</div>
))}
{isFetching && <LoadingSpinner />}
</div>
);
}
2. Animation Hook
function useAnimation(duration = 300, easing = 'ease-in-out') {
const [isAnimating, setIsAnimating] = useState(false);
const [progress, setProgress] = useState(0);
const animationRef = useRef();
const startTimeRef = useRef();
const startAnimation = useCallback(() => {
setIsAnimating(true);
startTimeRef.current = performance.now();
const animate = (currentTime) => {
const elapsed = currentTime - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
setProgress(progress);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate);
} else {
setIsAnimating(false);
}
};
animationRef.current = requestAnimationFrame(animate);
}, [duration]);
const stopAnimation = useCallback(() => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
setIsAnimating(false);
setProgress(0);
}
}, []);
useEffect(() => {
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
return {
isAnimating,
progress,
startAnimation,
stopAnimation
};
}
// Usage
function AnimatedModal({ isOpen, onClose }) {
const { progress, startAnimation } = useAnimation(300);
useEffect(() => {
if (isOpen) {
startAnimation();
}
}, [isOpen, startAnimation]);
if (!isOpen && progress === 0) return null;
return (
<div
style={{
opacity: progress,
transform: `scale(${0.8 + progress * 0.2})`
}}
>
<div className="modal-content">
{/* Modal content */}
</div>
</div>
);
}
Key Takeaways
- Custom hooks extract component logic into reusable functions
- Always start custom hook names with "use"
- Custom hooks can use other hooks, enabling composition
- Keep hooks pure and focused on a single responsibility
- Test custom hooks to ensure reliability
When to Create Custom Hooks
- When you find yourself copying logic between components
- When you want to separate concerns
- When you need to share stateful logic
- When you want to make complex logic testable