React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

custom-hookspatternsreusabilitycompositionhooks

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 composes useState, useEffect, and useCallback
  • SSR safety with the typeof window check
  • The hook returns a tuple with value, setter, and a remove function
  • as const narrows the return type from Array to 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 use and 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 use prefix is required — it signals to React (and linting tools) that the function follows the rules of hooks

Parameters & Return Values

PatternConventionExample
Single valueReturn the value directlyuseOnlineStatus() → boolean
Value + setterReturn a tuple [value, setter]useToggle() → [boolean, () => void]
Multiple valuesReturn an objectuseFetch() → { data, error, loading }
Actions onlyReturn an object of functionsuseClipboard() → { 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 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.

Alternatives

AlternativeUse WhenDon't Use When
Render propsYou need to share UI rendering logic, not just stateYou only need to share stateful logic
Higher-order components (HOC)Legacy codebase requires wrapping componentsStarting new code — hooks are simpler
Utility functionsLogic is pure (no hooks, no state, no effects)Logic involves React state or lifecycle
Context + ProviderThe shared state needs to be accessible by the entire subtreeEach consumer needs independent state
Third-party hooks (react-use, usehooks-ts)A well-tested implementation already existsYour 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
  • useCallback on signOut creates a stable reference so consumers using it in dependency arrays do not re-run effects
  • useMemo on the return object prevents unnecessary re-renders. Without it, a new object reference is created every render even if values are the same
  • isAuthenticated: user !== null is a derived value computed from state, not stored separately
  • Every component calling useAuth() gets its own state but they all sync via onAuthStateChange

FAQs

What makes a function a custom hook vs a regular utility function?
  • A custom hook starts with use and 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 const narrows 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 inside useEffect.
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 useEffect dependency array, the effect re-runs every render.
  • Fix by wrapping the return in useMemo or 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 useCallback to 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 => ...)) inside useCallback to minimize dependencies.
How do I test a custom hook in isolation?
  • Use renderHook from @testing-library/react to 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 -- useAuth can call useLocalStorage, which calls useState and useEffect.
  • 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.
  • 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