Custom hooks confused me for a long time. I knew they existed. I knew they started with use. But every tutorial showed the same useless useCounter example and I never saw the point.
Then I started building real apps and kept copy-pasting the same logic between components. Debounce logic here, localStorage sync there, the same fetch-loading-error pattern everywhere. That’s when custom hooks clicked. They’re not about being clever. They’re about not repeating yourself.
But there’s a trap on the other side too. I’ve seen codebases where every 5 lines of logic get extracted into a hook. That makes code harder to follow, not easier.
So here’s how I think about it now, with real hooks I actually use in my projects.
When to write a custom hook
My rule is simple: if I’ve written the same stateful logic in two or more components, it’s time for a hook. Not before. One component using some logic doesn’t justify a hook. That’s just moving code around for no reason.
Good candidates for hooks:
- Logic that combines multiple
useState/useEffectcalls - Behavior that needs cleanup (event listeners, timers, subscriptions)
- Stateful logic you reuse across unrelated components
useDebounce
This is probably the hook I use the most. Any time there’s a search input or an autocomplete, I need debouncing.
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
Usage:
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) {
searchApi(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
Without this hook, every component with search would have its own setTimeout / clearTimeout logic. It’s only 10 lines but you really don’t want to write it every time.
useLocalStorage
Syncing state with localStorage is something I do constantly. User preferences, theme selection, form drafts. The pattern is always the same: read from localStorage on mount, write back on change.
import { useState, useEffect } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
Usage:
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "dark");
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Current: {theme}
</button>
);
}
Works exactly like useState but persists across page reloads. I’ve used this in almost every project.
useMediaQuery
Responsive behavior sometimes needs to live in JavaScript, not just CSS. Showing a different component layout on mobile, for example.
import { useState, useEffect } from "react";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
setMatches(media.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, [query]);
return matches;
}
Usage:
function Navigation() {
const isMobile = useMediaQuery("(max-width: 768px)");
return isMobile ? <MobileNav /> : <DesktopNav />;
}
Without this hook you’d end up with window.matchMedia calls and event listeners scattered across components. The hook handles setup and cleanup in one place.
useFetch
I go back and forth on this one. For most projects I use React Query or SWR for data fetching. But for smaller projects where I don’t want an extra dependency, a simple fetch hook works fine.
import { useState, useEffect } from "react";
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchData() {
setLoading(true);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) setData(json);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error");
} finally {
if (!cancelled) setLoading(false);
}
}
fetchData();
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
Usage:
function UserProfile({ userId }: { userId: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <p>{data?.name}</p>;
}
The cancelled flag prevents state updates after the component unmounts. I’ve seen so many memory leak warnings from missing this.
useClickOutside
I use this for dropdowns, modals, and any floating element that should close when you click somewhere else.
import { useEffect, useRef } from "react";
function useClickOutside<T extends HTMLElement>(handler: () => void) {
const ref = useRef<T>(null);
useEffect(() => {
function listener(event: MouseEvent) {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler();
}
document.addEventListener("mousedown", listener);
return () => document.removeEventListener("mousedown", listener);
}, [handler]);
return ref;
}
Usage:
function Dropdown() {
const [open, setOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
return (
<div ref={ref}>
<button onClick={() => setOpen(!open)}>Menu</button>
{open && <ul><li>Option 1</li><li>Option 2</li></ul>}
</div>
);
}
Writing this inline every time would be messy. The hook keeps the event listener logic out of your component.
When NOT to write a hook
This is just as important. Don’t write a hook when:
It’s used in only one place. If only one component uses the logic, keep it in that component. Extract it later if you actually need it elsewhere.
It’s just a regular function. If your “hook” doesn’t use any React hooks internally (useState, useEffect, etc.), it’s not a hook. It’s a utility function. Put it in utils/.
// this is NOT a hook, it's just a function
function useFormatDate(date: Date) {
return date.toLocaleDateString("en-US");
}
// just make it a regular function
function formatDate(date: Date) {
return date.toLocaleDateString("en-US");
}
The abstraction makes things harder to follow. If someone reading your code needs to jump into the hook definition to understand what the component does, the hook is hurting more than helping. The component should still read clearly.
Keep them focused
A good custom hook does one thing. If your hook is managing form state, handling API calls, AND controlling a modal, you’ve got three hooks pretending to be one.
Write small hooks. Compose them in your components. That’s the whole idea.