Skip to main content
Technology & EngineeringReact Patterns189 lines

Custom Hooks

Custom hooks pattern for extracting and reusing stateful logic across React components

Quick Summary18 lines
You are an expert in the Custom Hooks pattern for building React applications.

## Key Points

- **Naming Convention**: Must start with `use` so React can enforce the Rules of Hooks.
- **Composition**: Custom hooks compose — a hook can call other custom hooks.
- **Isolation**: Each call to a hook gets its own independent state; hooks do not share state between components unless they read from an external store.
- **Return Value Flexibility**: A hook can return a single value, a tuple, or an object — choose based on ergonomics.
- Return tuples (`[value, setter]`) for hooks with one primary value and one updater; return objects for hooks with many fields.
- Accept configuration as a single options object when a hook takes more than two parameters.
- Handle cleanup in `useEffect` return functions — cancel fetches, clear timers, remove listeners.
- Write hooks as pure logic — avoid rendering JSX inside a hook.
- Provide TypeScript generics for hooks that wrap user-defined data shapes.
- Write unit tests using `renderHook` from `@testing-library/react`.
- **Violating the Rules of Hooks**: Never call hooks conditionally or inside loops. Keep all hook calls at the top level of the function body.
- **Stale closures**: When an effect or callback captures a value that changes over time, use refs or include it in the dependency array.
skilldb get react-patterns-skills/Custom HooksFull skill: 189 lines
Paste into your CLAUDE.md or agent config

Custom Hooks — React Patterns

You are an expert in the Custom Hooks pattern for building React applications.

Overview

Custom hooks are JavaScript functions whose names start with use that encapsulate stateful logic built on top of React's built-in hooks. They let you extract component logic into reusable, testable units without changing the component hierarchy. A custom hook can call other hooks, manage local state, trigger effects, and return any value the consumer needs.

Core Philosophy

Custom hooks are React's answer to the oldest problem in software engineering: how do you share logic without sharing UI? Before hooks, the answer involved higher-order components and render props — both of which worked but distorted the component tree, made debugging harder, and tangled the ownership of props. Custom hooks solve this cleanly: extract the logic into a function, call it from any component, and each call gets its own independent state.

The power of custom hooks comes from composition. A useAuth hook might internally use useState, useEffect, and useContext. A useProtectedRoute hook might call useAuth and useRouter. Each hook is a self-contained unit of behavior that composes with others the same way functions compose in any programming language. This is not an accident — hooks are functions, and they inherit all the benefits of functional composition: testability, readability, and the ability to build complex behavior from simple pieces.

The discipline is knowing when to extract. Not every useState and useEffect pair deserves its own hook. Extract when you see the same stateful logic repeated across components, when a component's logic is complex enough to obscure its rendering intent, or when you want to test the logic independently of the UI. A good custom hook has a clear name that describes what it does, a minimal parameter list, and a return value that makes the consumer's code more readable, not less.

Core Concepts

  • Naming Convention: Must start with use so React can enforce the Rules of Hooks.
  • Composition: Custom hooks compose — a hook can call other custom hooks.
  • Isolation: Each call to a hook gets its own independent state; hooks do not share state between components unless they read from an external store.
  • Return Value Flexibility: A hook can return a single value, a tuple, or an object — choose based on ergonomics.

Implementation Patterns

useLocalStorage

import { useState, useEffect } from "react";

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch {
      // Storage full or unavailable
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue] as const;
}

// Usage
function Settings() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
  return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>{theme}</button>;
}

useFetch

import { useState, useEffect, useRef } from "react";

interface UseFetchResult<T> {
  data: T | null;
  error: Error | null;
  loading: boolean;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);
  const abortRef = useRef<AbortController | null>(null);

  const fetchData = () => {
    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();
      })
      .then((json) => {
        setData(json as T);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== "AbortError") {
          setError(err);
          setLoading(false);
        }
      });
  };

  useEffect(() => {
    fetchData();
    return () => abortRef.current?.abort();
  }, [url]);

  return { data, error, loading, refetch: fetchData };
}

useDebounce

import { useState, useEffect } from "react";

function useDebounce<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

// Usage
function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
  const { data } = useFetch<SearchResult[]>(`/api/search?q=${debouncedQuery}`);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

useMediaQuery

import { useState, useEffect } from "react";

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => window.matchMedia(query).matches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    setMatches(mql.matches);
    return () => mql.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

Best Practices

  • Return tuples ([value, setter]) for hooks with one primary value and one updater; return objects for hooks with many fields.
  • Accept configuration as a single options object when a hook takes more than two parameters.
  • Handle cleanup in useEffect return functions — cancel fetches, clear timers, remove listeners.
  • Write hooks as pure logic — avoid rendering JSX inside a hook.
  • Provide TypeScript generics for hooks that wrap user-defined data shapes.
  • Write unit tests using renderHook from @testing-library/react.

Common Pitfalls

  • Violating the Rules of Hooks: Never call hooks conditionally or inside loops. Keep all hook calls at the top level of the function body.
  • Stale closures: When an effect or callback captures a value that changes over time, use refs or include it in the dependency array.
  • Missing cleanup: Forgetting to abort fetches or clear timers causes state updates on unmounted components.
  • Returning unstable references: Returning a new object or array on every render defeats React.memo. Use useMemo or useCallback for returned values that consumers might depend on in their own dependency arrays.
  • Overly generic hooks: A hook that tries to do everything becomes hard to use. Prefer small, focused hooks that compose well.

Anti-Patterns

  • The "useEverything" kitchen-sink hook: Creating a single hook that manages form state, validation, API calls, and toast notifications. These hooks accumulate parameters, return enormous objects, and become impossible to test or reuse because every consumer is coupled to every concern.

  • Hooks that return JSX: Writing a custom hook that returns rendered elements (e.g., const { modal, openModal } = useModal()). This blurs the line between hooks and components, makes the rendering order unpredictable, and prevents consumers from controlling where the JSX appears in the tree.

  • Ignoring the dependency array contract: Omitting dependencies from useEffect or useMemo arrays to "prevent re-runs" instead of restructuring the code. This creates stale closures that read outdated values and produce bugs that only manifest under specific timing conditions.

  • Wrapping a single useState in a custom hook: Extracting const [count, setCount] = useState(0) into useCount() adds a layer of indirection with no benefit. Custom hooks earn their existence by combining multiple primitives or encapsulating non-trivial logic.

  • Conditionally calling hooks: Wrapping hook calls in if statements or returning early before all hooks have been called. This violates the Rules of Hooks, causes React to misalign hook state between renders, and produces cryptic errors that are hard to diagnose.

Install this skill directly: skilldb add react-patterns-skills

Get CLI access →