Articles › React
Building Custom Hooks in React: Patterns and Real Examples
Learn to extract reusable logic into custom React hooks — useFetch, useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside — with design rules and testing tips.
Custom hooks are the main tool React gives you to reuse stateful logic. Whenever two components need the same combination of state, effects and refs — fetching data, syncing with local storage, tracking window size — you can extract it into a function whose name starts with use. This guide shows how to design good custom hooks and gives you a toolbox of production-ready examples.
What is a custom hook?
A custom hook is simply a JavaScript function that:
- has a name starting with
use, and - calls other hooks.
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle];
}
function Faq() {
const [open, toggleOpen] = useToggle();
return <button onClick={toggleOpen}>{open ? "Hide" : "Show"} answer</button>;
}
Important: custom hooks share logic, not state. Each component that calls useToggle gets its own independent value.
Designing a good hook API
- Name it after what it does:
useDebounce,useOnlineStatus, notuseHelper. - Return what the caller needs: a tuple (
[value, setValue]) for 1–2 values, an object ({ data, error, isLoading }) for more. - Accept options for flexibility, with sensible defaults.
- Keep returned functions stable (
useCallback) so callers can use them as effect dependencies. - Clean up everything the hook sets up.
1. useDebounce
Delay a fast-changing value — ideal for search inputs.
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 400);
useEffect(() => {
if (debouncedQuery) searchArticles(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}
2. useLocalStorage
State that survives page reloads:
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue; // private mode, corrupted JSON…
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
/* quota exceeded or storage disabled */
}
}, [key, value]);
return [value, setValue];
}
const [theme, setTheme] = useLocalStorage("theme", "dark");
Note the defensive try/catch: storage can throw in private browsing or when it's full.
3. useFetch
A small data-fetching hook with loading, error and cancellation:
function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, isLoading: true });
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setState((s) => ({ ...s, isLoading: true, error: null }));
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setState({ data, error: null, isLoading: false }))
.catch((error) => {
if (error.name !== "AbortError") setState({ data: null, error, isLoading: false });
});
return () => controller.abort();
}, [url]);
return state;
}
const { data: articles, isLoading, error } = useFetch("/api/articles");
For real applications, prefer TanStack Query — it adds caching, deduplication and retries — but writing this hook teaches the core ideas.
4. useMediaQuery
React to CSS media queries in JavaScript:
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e) => setMatches(e.matches);
mql.addEventListener("change", onChange);
setMatches(mql.matches);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}
const isMobile = useMediaQuery("(max-width: 640px)");
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
5. useOnClickOutside
Close dropdowns and modals when the user clicks elsewhere:
function useOnClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) return;
handler(event);
};
document.addEventListener("pointerdown", listener);
return () => document.removeEventListener("pointerdown", listener);
}, [ref, handler]);
}
function Dropdown() {
const ref = useRef(null);
const [open, setOpen] = useState(false);
useOnClickOutside(ref, () => setOpen(false));
return <div ref={ref}>{/* … */}</div>;
}
If handler is an inline function, it changes every render and re-subscribes the listener. Wrap it in useCallback, or store the latest handler in a ref:
function useLatest(value) {
const ref = useRef(value);
useLayoutEffect(() => {
ref.current = value;
});
return ref;
}
6. usePrevious
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
const prevCount = usePrevious(count); // value from the previous render
7. Composing hooks
Custom hooks can use other custom hooks — that's where they really shine:
function useArticleSearch() {
const [query, setQuery] = useLocalStorage("lastSearch", "");
const debounced = useDebounce(query, 400);
const url = debounced ? `/api/articles?q=${encodeURIComponent(debounced)}` : null;
const { data, isLoading, error } = useFetch(url);
return { query, setQuery, results: data ?? [], isLoading, error };
}
The component now reads like a description of behavior, while the details live in small, focused, testable hooks.
Testing custom hooks
Use React Testing Library's renderHook:
import { renderHook, act } from "@testing-library/react";
test("useToggle flips the value", () => {
const { result } = renderHook(() => useToggle(false));
act(() => result.current[1]());
expect(result.current[0]).toBe(true);
});
For timing hooks like useDebounce, use fake timers (vi.useFakeTimers() or jest.useFakeTimers()).
When not to write a custom hook
- When the logic has no state or effects — a plain utility function is simpler.
- When it's used in only one place and isn't complex — wait until duplication appears.
- When you're wrapping a single
useStatejust to rename it.
Summary
Custom hooks let you extract and reuse stateful logic with a clean API. Name them clearly, return stable values, accept options, always clean up, and compose small hooks into bigger ones. A good set of hooks — useDebounce, useLocalStorage, useMediaQuery, useOnClickOutside — makes components shorter and your whole codebase easier to maintain.
FAQ
Do two components using the same hook share state?
No. Each call creates independent state. To share state, lift it up or use context or a state library.
Can custom hooks return JSX?
They can, but it's usually a sign it should be a component instead. Hooks return data and functions.
Must custom hooks start with "use"?
Yes. The prefix tells React's linter to apply the rules of hooks, which prevents subtle bugs.