Custom Hooks
Extract reusable stateful logic into functions that start with use — React's primary code reuse mechanism.
Recipe
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.
Working Example
"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:
- A fully typed custom hook (
useLocalStorage) that composesuseState,useEffect, anduseCallback - SSR safety with the
typeof windowcheck - The hook returns a tuple with value, setter, and a remove function
as constnarrows the return type fromArrayto a specific tuple- Two independent usages in the same component with different keys and types
Deep Dive
How It Works
- A custom hook is any function whose name starts with
useand calls other hooks - React tracks hooks by call order within each component — your custom hook's internal hooks are part of the calling component's hook list
- Each component instance that calls your custom hook gets its own independent state
- Custom hooks can call other custom hooks, enabling deep composition
- The
useprefix is required — it signals to React (and linting tools) that the function follows the rules of hooks
Parameters & Return Values
| 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 } |
Variations
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;
}TypeScript Notes
// 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;
}Gotchas
-
Not starting with
use— If your hook is namedgetToggleinstead ofuseToggle, the linter won't enforce rules of hooks, leading to subtle bugs. Fix: Always prefix custom hooks withuse. -
Calling hooks conditionally inside custom hooks — The rules of hooks apply inside custom hooks too. Fix: Never put
useStateoruseEffectinside anifblock or after an early return. -
Returning unstable references — Returning a new object
{ value, toggle }on every render causes consumers'useEffectdependencies to change every time. Fix: UseuseMemoto 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
useEffectinside your hook. -
Stale closures in returned callbacks — Callbacks returned from custom hooks may close over stale state if not wrapped in
useCallbackwith proper dependencies. Fix: UseuseCallbackfor any functions you return, or use the updater pattern.
Alternatives
| 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.
Real-World Example
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:
- Three concerns in one hook: initial session fetch, real-time auth changes, and sign-out
- Cleanup
subscription.unsubscribe()prevents memory leaks when the component unmounts useCallbackonsignOutcreates a stable reference so consumers using it in dependency arrays do not re-run effectsuseMemoon the return object prevents unnecessary re-renders. Without it, a new object reference is created every render even if values are the sameisAuthenticated: user !== nullis a derived value computed from state, not stored separately- Every component calling
useAuth()gets its own state but they all sync viaonAuthStateChange
FAQs
What makes a function a custom hook vs a regular utility function?
- A custom hook starts with
useand calls other hooks (useState,useEffect, etc.) internally. - A regular utility function is pure -- no hooks, no state, no effects.
- If your function doesn't call any hooks, it's a utility function and should not be prefixed with
use.
Do multiple components calling the same custom hook share state?
- No. Each component instance that calls a custom hook gets its own independent state.
- React tracks hooks by call order within each component.
- Shared state requires context, a global store, or lifting state up.
Why should I return a tuple with as const instead of an object?
// Without `as const`: (boolean | (() => void))[]
// With `as const`: readonly [boolean, () => void]
return [value, toggle] as const;as constnarrows the return type to a specific tuple, enabling correct destructuring types.- Without it, TypeScript infers a union array where both elements could be either type.
Gotcha: What happens if my custom hook name doesn't start with "use"?
- The React linter won't enforce the rules of hooks for the function.
- Hooks called inside it may be called conditionally or in loops without a warning.
- This leads to subtle bugs. Always prefix custom hooks with
use.
When should I extract logic into a custom hook vs keeping it in the component?
- Extract when the logic is reused in 2+ components.
- Also extract when a component's hook logic exceeds ~15 lines and has a clear, named responsibility.
- Don't extract logic used in only one component just for the sake of abstraction.
How do I handle SSR safety in a custom hook that accesses window or localStorage?
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;
});
// ...
}- Guard browser API access with
typeof window === "undefined"or keep it insideuseEffect.
Gotcha: Why does returning a new object from my custom hook cause re-renders in consumers?
- Returning
{ value, toggle }creates a new object reference on every render. - If a consumer uses the object in a
useEffectdependency array, the effect re-runs every render. - Fix by wrapping the return in
useMemoor returning a tuple instead.
How do I type a generic custom hook in TypeScript?
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];
}- Use a generic
<T>to make the hook reusable with any data type.
Should I use useCallback for functions returned from a custom hook?
- Yes. Functions returned from custom hooks should be wrapped in
useCallbackto provide stable references. - Without
useCallback, consumers that use the function in dependency arrays get a new reference each render. - Use the updater pattern (
setState(prev => ...)) insideuseCallbackto minimize dependencies.
How do I test a custom hook in isolation?
- Use
renderHookfrom@testing-library/reactto render the hook without a component. - Assert on the returned values and simulate updates with
act(). - Since the hook is just a function with hooks, testing it is straightforward once rendered.
Can custom hooks call other custom hooks?
- Yes. Custom hooks compose freely --
useAuthcan calluseLocalStorage, which callsuseStateanduseEffect. - React flattens the hook calls into a single ordered list per component.
- Deep composition is one of the main strengths of the custom hook pattern.
What is the difference between returning a tuple vs an object from a custom hook?
- Tuples (
[value, setter]) allow consumers to rename the variables:const [name, setName] = useLocalStorage(...). - Objects (
{ data, error, loading }) are better when there are many return values and order doesn't matter. - Use tuples for 1-3 values; use objects for 4+ values.
Related
- useState — the most common hook inside custom hooks
- useEffect — subscriptions and side effects in custom hooks
- useCallback — stabilize callbacks returned from custom hooks
- useRef — mutable values and DOM access in custom hooks
- useMemo — memoize expensive derived values inside custom hooks