Custom Hooks: Step-by-Step Guide
How to build custom hooks from scratch - starting simple, building to intermediate complexity.
Search across all documentation pages
How to build custom hooks from scratch - starting simple, building to intermediate complexity.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
use - React relies on this naming convention to apply the Rules of Hooks.useState, useEffect, useRef, useCallback, etc. go into your function.useCallback and return objects in useMemo to prevent unnecessary re-renders in consumers.useEffect return functions.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.
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.
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.
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>;
}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:
useState avoids reading localStorage on every render.typeof window === "undefined" guard makes it SSR-safe.<T> lets it store strings, numbers, objects, arrays.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:
delay ms of inactivity.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:
mousedown (not click) so the dropdown closes before the click completes.handler should be wrapped in useCallback in the consumer to avoid re-attaching listeners every render.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 />;
}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:
url changes - prevents race conditions.AbortError is silently caught so it doesn't show as an error state.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:
handlerRef pattern: the listener is stable (never re-attached), but always calls the latest handler. This avoids stale closures without requiring handler in the dependency array.K extends keyof WindowEventMap gives full autocomplete on event names and typed event objects.The use prefix 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 + useEffect to keep it fresh) instead of adding the callback to the dependency array.
Returning new objects/arrays every render. If your hook returns { value, toggle } without useMemo, 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, or matchMedia need 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 infer unknown. 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 useEffect inside 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 renderHook from @testing-library/react - you cannot call hooks outside of a component. Wrap in act() when triggering state updates.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥