Custom Hooks: Step-by-Step Guide
How to build custom hooks from scratch — starting simple, building to intermediate complexity.
Step-by-Step: How to Make a Custom Hook
- Identify repeated logic — If two or more components share the same stateful logic, extract it.
- Create a function that starts with
use— React relies on this naming convention to apply the Rules of Hooks. - Move the hooks inside —
useState,useEffect,useRef,useCallback, etc. go into your function. - Define the return value — Return whatever the consuming component needs: a value, a setter, an object, a tuple.
- Add TypeScript types — Type the parameters and return value. Use generics when the hook is value-agnostic.
- Memoize stable references — Wrap callbacks in
useCallbackand return objects inuseMemoto prevent unnecessary re-renders in consumers. - Handle cleanup — If your hook sets up subscriptions, timers, or listeners, clean them up in
useEffectreturn functions.
Simple Hooks
1. useToggle — Boolean state with toggle
The simplest useful hook. Wraps useState(boolean) and returns a stable toggle function.
import { useState, useCallback } from "react";
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const;
}Usage:
function DarkModeButton() {
const [isDark, toggleDark] = useToggle(false);
return <button onClick={toggleDark}>{isDark ? "Light" : "Dark"}</button>;
}Why it works: useCallback with an empty dependency array creates a stable function reference. The updater form (v) => !v avoids stale closures.
2. useDocumentTitle — Set the page title reactively
import { useEffect } from "react";
function useDocumentTitle(title: string) {
useEffect(() => {
document.title = title;
}, [title]);
}Usage:
function ProfilePage({ user }: { user: { name: string } }) {
useDocumentTitle(`${user.name} — Profile`);
return <h1>{user.name}</h1>;
}Why it works: The effect runs only when title changes. No cleanup needed because setting document.title is idempotent.
3. useCounter — Numeric state with increment/decrement/reset
import { useState, useCallback, useMemo } from "react";
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return useMemo(
() => ({ count, increment, decrement, reset }),
[count, increment, decrement, reset]
);
}Usage:
function CartQuantity() {
const { count, increment, decrement } = useCounter(1);
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}Why it works: Returning a memoized object means consumers that destructure won't cause child re-renders from a new object reference every render.
4. useIsMounted — Check if the component is still mounted
Useful for guarding async callbacks that resolve after unmount.
import { useRef, useEffect, useCallback } from "react";
function useIsMounted() {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
return useCallback(() => mounted.current, []);
}Usage:
function AsyncButton({ onClick }: { onClick: () => Promise<void> }) {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
await onClick();
if (isMounted()) setLoading(false); // safe guard
};
return <button onClick={handleClick} disabled={loading}>Go</button>;
}Intermediate Hooks
5. useLocalStorage — Persist state to localStorage with SSR safety
import { useState, useEffect, useCallback } from "react";
function useLocalStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initialValue;
try {
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// storage full or blocked
}
}, [key, value]);
const remove = useCallback(() => {
setValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [value, setValue, remove] as const;
}Usage:
function ThemeSelector() {
const [theme, setTheme] = useLocalStorage("theme", "light");
return (
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
);
}Key details:
- Lazy initializer in
useStateavoids reading localStorage on every render. typeof window === "undefined"guard makes it SSR-safe.- Generic
<T>lets it store strings, numbers, objects, arrays.
6. useDebounce — Delay value updates until input settles
import { useState, useEffect } from "react";
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}Usage:
function SearchInput() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}Key details:
- Each keystroke clears the previous timer via cleanup.
- The debounced value only updates after
delayms of inactivity.
7. useClickOutside — Detect clicks outside a ref element
import { useEffect, useRef } from "react";
function useClickOutside<T extends HTMLElement>(
handler: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
const listener = (e: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(e.target as Node)) return;
handler();
};
document.addEventListener("mousedown", listener);
document.addEventListener("touchstart", listener);
return () => {
document.removeEventListener("mousedown", listener);
document.removeEventListener("touchstart", 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(true)}>Menu</button>
{open && <ul><li>Option A</li><li>Option B</li></ul>}
</div>
);
}Key details:
- Uses
mousedown(notclick) so the dropdown closes before the click completes. - Both mouse and touch events are handled for mobile.
- The
handlershould be wrapped inuseCallbackin the consumer to avoid re-attaching listeners every render.
8. useMediaQuery — Reactive CSS media query matching
import { useState, useEffect } from "react";
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", onChange);
setMatches(mql.matches); // sync in case it changed before effect ran
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}Usage:
function ResponsiveLayout() {
const isMobile = useMediaQuery("(max-width: 768px)");
return isMobile ? <MobileNav /> : <DesktopNav />;
}9. useFetch — Data fetching with loading/error states and abort
import { useState, useEffect, useRef } from "react";
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
})
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}Usage:
function UserProfile({ id }: { id: string }) {
const { data, loading, error } = useFetch<User>(`/api/users/${id}`);
if (loading) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;
return <h1>{data?.name}</h1>;
}Key details:
- Previous requests are aborted when
urlchanges — prevents race conditions. AbortErroris silently caught so it doesn't show as an error state.- For production, consider libraries like TanStack Query instead; this is for learning the pattern.
10. useEventListener — Type-safe, declarative event binding
import { useEffect, useRef } from "react";
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
element?: HTMLElement | null
) {
const handlerRef = useRef(handler);
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const target = element ?? window;
const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K]);
target.addEventListener(event, listener);
return () => target.removeEventListener(event, listener);
}, [event, element]);
}Usage:
function EscapeHandler({ onEscape }: { onEscape: () => void }) {
useEventListener("keydown", (e) => {
if (e.key === "Escape") onEscape();
});
return null;
}Key details:
handlerRefpattern: the listener is stable (never re-attached), but always calls the latest handler. This avoids stale closures without requiringhandlerin the dependency array.- Generic
K extends keyof WindowEventMapgives full autocomplete on event names and typed event objects.
Gotchas
-
The
useprefix is required. React's linter and rules of hooks depend on it.toggleState()won't get lint warnings if you break hook rules inside it. -
Don't call hooks conditionally inside your custom hook. The same rules apply — hooks must be called in the same order every render.
-
Stale closures in effects. If your hook captures a callback prop in
useEffect, use a ref (useRef+useEffectto keep it fresh) instead of adding the callback to the dependency array. -
Returning new objects/arrays every render. If your hook returns
{ value, toggle }withoutuseMemo, every consumer re-renders when the hook's parent re-renders — even if nothing changed. -
Missing cleanup. Forgetting to clear timers, remove listeners, or abort fetches causes memory leaks and state updates on unmounted components.
-
SSR hydration mismatches. Hooks that read
window,document,localStorage, ormatchMedianeed guards (typeof window === "undefined") and sometimes a two-pass render strategy. -
Overusing custom hooks. Not everything needs to be a hook. If the logic is pure (no hooks inside), make it a plain function. If it's only used in one component and unlikely to be reused, keep it inline.
-
Generic type inference. When using generics like
useLocalStorage<T>, ensure the initial value matches the generic or TypeScript will inferunknown. Provide explicit type arguments when the initial value is ambiguous (e.g.,null). -
Effect dependencies with objects/arrays. Passing an object or array as a dependency to
useEffectinside a custom hook causes it to re-run every render (new reference). Destructure to primitives or use a deep-compare utility. -
Testing custom hooks. Use
renderHookfrom@testing-library/react— you cannot call hooks outside of a component. Wrap inact()when triggering state updates.