Custom Hooks
Extract reusable stateful logic into functions that start with use - React's primary code reuse mechanism.
Search across all documentation pages
Extract reusable stateful logic into functions that start with use - React's primary code reuse mechanism.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// Pattern: a custom hook is just a function that calls other hooks
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle] as const;
}
// Usage
const [isOpen, toggleOpen] = useToggle(false);When to reach for this: You find yourself duplicating the same combination of useState, useEffect, useRef, or other hooks across multiple components.
"use client";
import { useCallback, useEffect, useState } from "react";
// Custom hook: local storage state
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 unavailable
}
}, [key, value]);
const remove = useCallback(() => {
setValue(initialValue);
localStorage.removeItem(key);
}, [key, initialValue]);
return [value, setValue, remove] as const;
}
// Component using the custom hook
export function Preferences() {
const [name, setName, clearName] = useLocalStorage("user-name", "");
const [darkMode, setDarkMode] = useLocalStorage("dark-mode", false);
return (
<div className={`space-y-4 p-4 rounded ${darkMode ? "bg-gray-900 text-white" : "bg-white"}`}>
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="border rounded px-3 py-2 text-black"
placeholder="Enter your name"
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={darkMode}
onChange={(e) => setDarkMode(e.target.checked)}
/>
<span className="text-sm">Dark mode</span>
</label>
<div className="flex gap-2">
<button onClick={clearName} className="text-sm text-blue-500 underline">
Clear name
</button>
</div>
{name && <p className="text-sm">Hello, {name}!</p>}
</div>
);
}What this demonstrates:
useLocalStorage) that composes useState, useEffect, and useCallbacktypeof window checkas const narrows the return type from Array to a specific tupleuse and calls other hooksuse prefix is required - it signals to React (and linting tools) that the function follows the rules of hooks| Pattern | Convention | Example |
|---|---|---|
| Single value | Return the value directly | useOnlineStatus() → boolean |
| Value + setter | Return a tuple [value, setter] | useToggle() → [boolean, () => void] |
| Multiple values | Return an object | useFetch() → { data, error, loading } |
| Actions only | Return an object of functions | useClipboard() → { copy, paste } |
useDebounce - debounce a fast-changing value:
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
const debouncedQuery = useDebounce(query, 300);useFetch - data fetching with loading and error states:
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((json) => { setData(json); setError(null); })
.catch((err) => { if (err.name !== "AbortError") setError(err); })
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, error, loading };
}useMediaQuery - responsive breakpoints:
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
function handler(e: MediaQueryListEvent) {
setMatches(e.matches);
}
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
// Usage
const isMobile = useMediaQuery("(max-width: 768px)");useClickOutside - detect clicks outside a ref:
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
handler();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [ref, handler]);
}usePrevious - track the previous value:
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}// Use generics for reusable hooks
function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] { ... }
// Use `as const` for tuple returns so destructuring types are correct
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue(v => !v), []);
return [value, toggle] as const;
// Return type: readonly [boolean, () => void]
// Without `as const`: (boolean | (() => void))[]
}
// Use overloads for hooks with multiple call signatures
function useControllable<T>(value: T): [T, (v: T) => void];
function useControllable<T>(value: undefined, defaultValue: T): [T, (v: T) => void];
function useControllable<T>(value: T | undefined, defaultValue?: T) {
const [internal, setInternal] = useState(defaultValue ?? value!);
if (value !== undefined) return [value, () => {}] as const;
return [internal, setInternal] as const;
}Not starting with use - If your hook is named getToggle instead of useToggle, the linter won't enforce rules of hooks, leading to subtle bugs. Fix: Always prefix custom hooks with use.
Calling hooks conditionally inside custom hooks - The rules of hooks apply inside custom hooks too. Fix: Never put useState or useEffect inside an if block or after an early return.
Returning unstable references - Returning a new object { value, toggle } on every render causes consumers' useEffect dependencies to change every time. Fix: Use useMemo to stabilize objects, or return a tuple.
Over-abstracting - Creating a custom hook for logic used in only one component adds indirection without benefit. Fix: Extract into a custom hook only when the logic is used in 2+ components or when it improves readability of a complex component.
Missing cleanup - Forgetting to clean up subscriptions, timers, or event listeners in your custom hook causes memory leaks. Fix: Always return a cleanup function from useEffect inside your hook.
Stale closures in returned callbacks - Callbacks returned from custom hooks may close over stale state if not wrapped in useCallback with proper dependencies. Fix: Use useCallback for any functions you return, or use the updater pattern.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Render props | You need to share UI rendering logic, not just state | You only need to share stateful logic |
| Higher-order components (HOC) | Legacy codebase requires wrapping components | Starting new code - hooks are simpler |
| Utility functions | Logic is pure (no hooks, no state, no effects) | Logic involves React state or lifecycle |
| Context + Provider | The shared state needs to be accessible by the entire subtree | Each consumer needs independent state |
| Third-party hooks (react-use, usehooks-ts) | A well-tested implementation already exists | Your use case is unique to your domain |
When to extract a custom hook: If you have 2+ components with the same useState + useEffect combination, or if a component's hook logic exceeds ~15 lines and has a clear responsibility, extract it.
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Auth hook with session + real-time subscription
// File: src/hooks/use-auth.ts
'use client'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { type User } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase/client'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const getSession = async () => {
try {
const { data: { session } } = await supabase.auth.getSession()
setUser(session?.user ?? null)
} catch (error) {
console.error('Error getting session:', error)
} finally {
setLoading(false)
}
}
getSession()
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
setUser(session?.user ?? null)
setLoading(false)
}
)
return () => { subscription.unsubscribe() }
}, [])
const signOut = useCallback(async () => {
try {
await supabase.auth.signOut()
setUser(null)
} catch (error) {
console.error('Error signing out:', error)
}
}, [])
return useMemo(() => ({
user,
loading,
signOut,
isAuthenticated: user !== null,
userId: user?.id || null,
}), [user, loading, signOut])
}What this demonstrates in production:
subscription.unsubscribe() prevents memory leaks when the component unmountsuseCallback on signOut creates a stable reference so consumers using it in dependency arrays do not re-run effectsuseMemo on the return object prevents unnecessary re-renders. Without it, a new object reference is created every render even if values are the sameisAuthenticated: user !== null is a derived value computed from state, not stored separatelyuseAuth() gets its own state but they all sync via onAuthStateChangeuse and calls other hooks (useState, useEffect, etc.) internally.use.// Without `as const`: (boolean | (() => void))[]
// With `as const`: readonly [boolean, () => void]
return [value, toggle] as const;as const narrows the return type to a specific tuple, enabling correct destructuring types.use.function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initial;
});
// ...
}typeof window === "undefined" or keep it inside useEffect.{ value, toggle } creates a new object reference on every render.useEffect dependency array, the effect re-runs every render.useMemo or returning a tuple instead.function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return initial;
const stored = localStorage.getItem(key);
return stored ? (JSON.parse(stored) as T) : initial;
});
// ...
return [value, setValue];
}<T> to make the hook reusable with any data type.useCallback to provide stable references.useCallback, consumers that use the function in dependency arrays get a new reference each render.setState(prev => ...)) inside useCallback to minimize dependencies.renderHook from @testing-library/react to render the hook without a component.act().useAuth can call useLocalStorage, which calls useState and useEffect.[value, setter]) allow consumers to rename the variables: const [name, setName] = useLocalStorage(...).{ data, error, loading }) are better when there are many return values and order doesn't matter.Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥