The Custom Hook I Wish I’d Written Sooner
Three months into my first React job, I was copy-pasting the same data fetching logic across 15 different components. Same pattern every time: useState, useEffect, loading states, error handling.
A senior dev saw my PR and asked: “Have you heard of custom hooks?”
That moment changed how I write React.
What Makes a Good Custom Hook?
- Solves a real problem (not just “because I can”)
- Has a single responsibility (does one thing well)
- Is reusable (works in multiple contexts)
- Has a clear API (easy to understand what it returns)
- Handles edge cases (cleanup, error states, race conditions)
The Data Fetching Hook
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let isCancelled = false; // Race condition handling
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (!isCancelled) {
setData(result);
setLoading(false);
}
} catch (err) {
if (!isCancelled) {
setError(err.message);
setLoading(false);
}
}
};
fetchData();
return () => { isCancelled = true; };
}, [url, JSON.stringify(options)]);
return { data, loading, error };
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
return <div>{user.name}</div>;
}
The LocalStorage Hook
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
// Listen for changes in other tabs
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue));
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setStoredValue];
}
// Usage
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
Essential Hooks Collection
useDebounce
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
usePrevious
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
useMediaQuery
function useMediaQuery(query) {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false
);
useEffect(() => {
const mediaQuery = window.matchMedia(query);
const handleChange = () => setMatches(mediaQuery.matches);
handleChange();
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [query]);
return matches;
}
useIntersectionObserver (Lazy Loading & Infinite Scroll)
function useIntersectionObserver(options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const elementRef = useRef(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(element);
return () => observer.disconnect();
}, [options.threshold, options.root, options.rootMargin]);
return [elementRef, isIntersecting];
}
Common Mistakes
Mistake 1: Object Dependencies
// DON'T - options is new every render
function useFetch(url, options) {
useEffect(() => {
fetch(url, options);
}, [url, options]);
}
// DO - use ref for stable reference
function useFetch(url, options) {
const optionsRef = useRef(options);
useEffect(() => {
optionsRef.current = options;
});
useEffect(() => {
fetch(url, optionsRef.current);
}, [url]);
}
Mistake 2: Not Cleaning Up
// DON'T
function useInterval(callback, delay) {
useEffect(() => {
setInterval(callback, delay); // Memory leak!
}, [callback, delay]);
}
// DO
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
}
When NOT to Use Custom Hooks
- It’s just a regular function - If it doesn’t use hooks, it’s just a function
- It’s only used once - Don’t over-abstract
- It’s too specific -
useHandleButtonClickForModalOnDashboardis too specific - It’s too generic -
useDatathat tries to handle everything
My Starter Pack
export { default as useFetch } from './useFetch';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useDebounce } from './useDebounce';
export { default as useMediaQuery } from './useMediaQuery';
export { default as usePrevious } from './usePrevious';
export { default as useIntersectionObserver } from './useIntersectionObserver';
These six hooks solve 90% of common React problems.
Custom hooks are one of React’s superpowers. They let you extract complex logic, make it reusable, and keep your components clean. Start with simple hooks, build your utility library, and watch your code quality improve.
Happy hooking!