React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

skillscustom-hooksreacttypescripttestingcomposition

Custom Hooks Crafting Skill - A Claude Code skill recipe for creating reusable, well-tested custom React hooks

These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.

Recipe

The complete SKILL.md content you can copy into .claude/skills/custom-hooks-crafting/SKILL.md:

---
name: custom-hooks-crafting
description: "Creating reusable, well-tested custom React hooks with proper TypeScript. Use when asked to: create a hook, custom hook, extract hook, reusable hook, hook composition, test a hook."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
 
# Custom Hooks Crafting
 
You are an expert in crafting custom React hooks. Every hook you create follows strict design principles, is fully typed, SSR-safe, and testable.
 
## Hook Design Rules
 
1. **Single responsibility** - Each hook does exactly one thing
2. **Name starts with `use`** - Always prefix with `use` (React enforces this)
3. **Return type conventions:**
   - Single value: return the value directly
   - Value + setter: return a tuple `[value, setter]` (like useState)
   - Multiple related values: return an object `{ value, loading, error }`
4. **Accept configuration as an object** - When a hook takes more than 2 parameters, use an options object
5. **Provide sensible defaults** - Every option should have a default value
6. **Clean up after yourself** - Always return cleanup functions from useEffect
7. **SSR safety** - Check `typeof window !== "undefined"` before accessing browser APIs
8. **Stable references** - Wrap returned functions in useCallback, wrap returned objects in useMemo
 
## Hook Creation Template
 
```tsx
import \{ useState, useEffect, useCallback, useRef \} from "react";
 
interface UseMyHookOptions \{
  /** Description of option */
  enabled?: boolean;
  /** Description of option */
  interval?: number;
\}
 
interface UseMyHookReturn \{
  /** Description of return value */
  data: string | null;
  /** Description of return value */
  loading: boolean;
  /** Description of return value */
  error: Error | null;
  /** Description of return value */
  reset: () => void;
\}
 
/**
 * Description of what the hook does and when to use it.
 *
 * @example
 * const \{ data, loading, error \} = useMyHook(\{ enabled: true \});
 */
export function useMyHook(options: UseMyHookOptions = \{\}): UseMyHookReturn \{
  const \{ enabled = true, interval = 1000 \} = options;
 
  const [data, setData] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
 
  // Use ref for values needed in effects but that should not trigger re-runs
  const intervalRef = useRef(interval);
  intervalRef.current = interval;
 
  useEffect(() => \{
    if (!enabled) return;
 
    let cancelled = false;
    setLoading(true);
 
    async function fetchData() \{
      try \{
        const result = await someAsyncOperation();
        if (!cancelled) \{
          setData(result);
          setLoading(false);
        \}
      \} catch (err) \{
        if (!cancelled) \{
          setError(err instanceof Error ? err : new Error(String(err)));
          setLoading(false);
        \}
      \}
    \}
 
    fetchData();
    return () => \{ cancelled = true; \};
  \}, [enabled]);
 
  const reset = useCallback(() => \{
    setData(null);
    setLoading(false);
    setError(null);
  \}, []);
 
  return \{ data, loading, error, reset \};
\}

Common Hook Recipes

useDebounce

export function useDebounce<T>(value: T, delay: number): T \{
  const [debouncedValue, setDebouncedValue] = useState(value);
 
  useEffect(() => \{
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  \}, [value, delay]);
 
  return debouncedValue;
\}

useMediaQuery

export function useMediaQuery(query: string): boolean \{
  const [matches, setMatches] = useState(false);
 
  useEffect(() => \{
    if (typeof window === "undefined") return;
 
    const media = window.matchMedia(query);
    setMatches(media.matches);
 
    const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
    media.addEventListener("change", listener);
    return () => media.removeEventListener("change", listener);
  \}, [query]);
 
  return matches;
\}

useClickOutside

export function useClickOutside<T extends HTMLElement>(
  handler: () => void
): React.RefObject<T | null> \{
  const ref = useRef<T | null>(null);
 
  useEffect(() => \{
    const listener = (event: MouseEvent | TouchEvent) => \{
      if (!ref.current || ref.current.contains(event.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;
\}

useLocalStorage

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] \{
  const [storedValue, setStoredValue] = useState<T>(() => \{
    if (typeof window === "undefined") return initialValue;
    try \{
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    \} catch \{
      return initialValue;
    \}
  \});
 
  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => \{
      setStoredValue((prev) => \{
        const next = value instanceof Function ? value(prev) : value;
        if (typeof window !== "undefined") \{
          window.localStorage.setItem(key, JSON.stringify(next));
        \}
        return next;
      \});
    \},
    [key]
  );
 
  return [storedValue, setValue];
\}

useIntersectionObserver

interface UseIntersectionOptions \{
  threshold?: number;
  rootMargin?: string;
  triggerOnce?: boolean;
\}
 
export function useIntersectionObserver<T extends HTMLElement>(
  options: UseIntersectionOptions = \{\}
): [React.RefObject<T | null>, boolean] \{
  const \{ threshold = 0, rootMargin = "0px", triggerOnce = false \} = options;
  const ref = useRef<T | null>(null);
  const [isVisible, setIsVisible] = useState(false);
 
  useEffect(() => \{
    const element = ref.current;
    if (!element || typeof IntersectionObserver === "undefined") return;
 
    const observer = new IntersectionObserver(
      ([entry]) => \{
        setIsVisible(entry.isIntersecting);
        if (entry.isIntersecting && triggerOnce) \{
          observer.unobserve(element);
        \}
      \},
      \{ threshold, rootMargin \}
    );
 
    observer.observe(element);
    return () => observer.disconnect();
  \}, [threshold, rootMargin, triggerOnce]);
 
  return [ref, isVisible];
\}

usePrevious

export function usePrevious<T>(value: T): T | undefined \{
  const ref = useRef<T | undefined>(undefined);
 
  useEffect(() => \{
    ref.current = value;
  \});
 
  return ref.current;
\}

When to Extract a Hook

Extract a custom hook when:

  • The same useState + useEffect pattern appears in 2+ components
  • A component has complex state logic that obscures the render logic
  • You need to share behavior (not UI) between components
  • Testing the logic independently from the component would be valuable

Do NOT extract a hook when:

  • The logic is used in only one component and is simple
  • You are just wrapping a single useState (adds indirection without benefit)
  • The "hook" does not use any React hooks internally (make it a plain function)

Testing Template

import \{ renderHook, act, waitFor \} from "@testing-library/react";
import \{ useMyHook \} from "./useMyHook";
 
describe("useMyHook", () => \{
  it("returns initial state", () => \{
    const \{ result \} = renderHook(() => useMyHook());
    expect(result.current.data).toBeNull();
    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBeNull();
  \});
 
  it("updates state on action", () => \{
    const \{ result \} = renderHook(() => useMyHook(\{ enabled: true \}));
    act(() => \{
      result.current.reset();
    \});
    expect(result.current.data).toBeNull();
  \});
 
  it("handles async operations", async () => \{
    const \{ result \} = renderHook(() => useMyHook(\{ enabled: true \}));
    await waitFor(() => \{
      expect(result.current.loading).toBe(false);
    \});
    expect(result.current.data).toBeDefined();
  \});
 
  it("cleans up on unmount", () => \{
    const \{ unmount \} = renderHook(() => useMyHook());
    // Verify no errors or warnings on unmount
    expect(() => unmount()).not.toThrow();
  \});
 
  it("responds to option changes", () => \{
    const \{ result, rerender \} = renderHook(
      (\{ enabled \}) => useMyHook(\{ enabled \}),
      \{ initialProps: \{ enabled: false \} \}
    );
    expect(result.current.loading).toBe(false);
    rerender(\{ enabled: true \});
    expect(result.current.loading).toBe(true);
  \});
\});

Hook Composition Rules

  • Hooks can call other hooks (that is the composition model)
  • Keep the dependency chain shallow (hook A -> hook B is fine; A -> B -> C -> D is a smell)
  • If composing hooks, ensure cleanup propagates correctly
  • Use refs to break effect dependency cycles

## Working Example

### Example 1: User asks "Create a hook for copying text to clipboard"

**User prompt:** "I need a hook that copies text to the clipboard and shows a success state for 2 seconds."

**Skill-guided response would produce:**

```tsx
// hooks/use-clipboard.ts
import { useState, useCallback, useRef } from "react";

interface UseClipboardOptions {
  /** How long to show the copied state in ms */
  successDuration?: number;
}

interface UseClipboardReturn {
  copied: boolean;
  copy: (text: string) => Promise<void>;
  error: Error | null;
}

export function useClipboard(
  options: UseClipboardOptions = {}
): UseClipboardReturn {
  const { successDuration = 2000 } = options;
  const [copied, setCopied] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>();

  const copy = useCallback(
    async (text: string) => {
      if (typeof navigator === "undefined" || !navigator.clipboard) {
        setError(new Error("Clipboard API not available"));
        return;
      }

      try {
        await navigator.clipboard.writeText(text);
        setCopied(true);
        setError(null);

        if (timeoutRef.current) clearTimeout(timeoutRef.current);
        timeoutRef.current = setTimeout(() => setCopied(false), successDuration);
      } catch (err) {
        setError(err instanceof Error ? err : new Error("Copy failed"));
        setCopied(false);
      }
    },
    [successDuration]
  );

  return { copied, copy, error };
}
// hooks/use-clipboard.test.ts
import { renderHook, act } from "@testing-library/react";
import { useClipboard } from "./use-clipboard";
 
// Mock clipboard API
Object.assign(navigator, {
  clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
 
describe("useClipboard", () => {
  it("copies text and sets copied to true", async () => {
    const { result } = renderHook(() => useClipboard());
    await act(async () => {
      await result.current.copy("hello");
    });
    expect(result.current.copied).toBe(true);
    expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello");
  });
 
  it("resets copied after successDuration", async () => {
    vi.useFakeTimers();
    const { result } = renderHook(() =>
      useClipboard({ successDuration: 1000 })
    );
    await act(async () => {
      await result.current.copy("hello");
    });
    expect(result.current.copied).toBe(true);
    act(() => vi.advanceTimersByTime(1000));
    expect(result.current.copied).toBe(false);
    vi.useRealTimers();
  });
});

Example 2: User asks "Extract this repeated logic into a hook"

Skill-guided response would: Identify the shared state/effect pattern, extract it following the template, add proper TypeScript types, ensure SSR safety, and provide tests.

Deep Dive

How the Skill Works

This skill provides:

  1. Design rules - Naming, return types, parameter conventions
  2. Creation template - A copy-paste starting point for any new hook
  3. Recipe library - 6 production-ready hook implementations
  4. Testing template - Standardized test structure for hooks
  5. Extraction criteria - When to extract and when not to

Customization

  • Add your project's hook naming conventions
  • Include project-specific hooks as examples
  • Specify testing framework preferences (Vitest vs Jest)
  • Add performance requirements (e.g., "all hooks must support React Compiler")

How to Install

mkdir -p .claude/skills/custom-hooks-crafting
# Paste the Recipe content into .claude/skills/custom-hooks-crafting/SKILL.md

Gotchas

  • useEffect cleanup runs before every re-run, not just on unmount - Design cleanup functions to handle both cases.
  • useState initializer functions run only once - useState(() => expensiveComputation()) is different from useState(expensiveComputation()).
  • Refs do not trigger re-renders - If you need the UI to update, you need state, not refs.
  • Custom hooks share logic, not state - Two components calling the same hook each get their own independent state.
  • Hooks cannot be called conditionally - But use() in React 19 can be.

Alternatives

ApproachWhen to Use
Plain utility functionsLogic that does not need React hooks
Higher-order componentsWrapping behavior around a component (legacy pattern)
Render propsSharing behavior with render control (legacy pattern)
Zustand selectorsShared state across components (not just shared logic)

FAQs

When should you extract logic into a custom hook versus keeping it in the component?
  • Extract when the same useState + useEffect pattern appears in 2+ components.
  • Extract when complex state logic obscures the render logic.
  • Do not extract if logic is used in one component and is simple, or if it wraps a single useState.
Why must custom hook names start with "use"?
  • React enforces this convention to identify functions that may call other hooks.
  • The linter uses the use prefix to apply the Rules of Hooks (no conditional calls, no calls in loops).
  • Without the prefix, React cannot verify correct hook usage at lint time.
What is the correct return type convention for a custom hook?
  • Single value: return it directly.
  • Value + setter: return a tuple [value, setter] (like useState).
  • Multiple related values: return an object { value, loading, error }.
How do you type a generic custom hook like useLocalStorage in TypeScript?
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // T is inferred from initialValue
}
How do you make a custom hook SSR-safe?
  • Check typeof window !== "undefined" before accessing browser APIs like localStorage, navigator, or matchMedia.
  • Return a sensible default during SSR (e.g., false for useMediaQuery).
Gotcha: Do two components calling the same custom hook share state?
  • No. Each component gets its own independent state instance.
  • Custom hooks share logic, not state.
  • For shared state, use a state management library like Zustand or React Context.
Why should returned functions from hooks be wrapped in useCallback?
  • Without useCallback, the function reference changes on every render.
  • Child components receiving the function as a prop will re-render unnecessarily.
  • Stable references prevent cascading re-renders.
How do you test a custom hook using React Testing Library?
import { renderHook, act } from "@testing-library/react";
import { useMyHook } from "./useMyHook";
 
it("returns initial state", () => {
  const { result } = renderHook(() => useMyHook());
  expect(result.current.data).toBeNull();
});
 
it("updates on action", () => {
  const { result } = renderHook(() => useMyHook());
  act(() => result.current.reset());
  expect(result.current.data).toBeNull();
});
Gotcha: What is the difference between useState(fn) and useState(fn())?
  • useState(() => expensiveComputation()) runs the function only once on initial render (lazy initializer).
  • useState(expensiveComputation()) runs the function on every render, wasting computation.
What is the useRef pattern for avoiding stale closures in useEffect?
  • Store the latest callback in a ref: handlerRef.current = handler.
  • In the effect, call handlerRef.current(...) instead of handler(...).
  • This lets the effect closure stay stable (empty deps) while always calling the latest function.
How do you type the return value of useClickOutside in TypeScript?
function useClickOutside<T extends HTMLElement>(
  handler: () => void
): React.RefObject<T | null> {
  const ref = useRef<T | null>(null);
  // attach document listeners, check ref.current.contains
  return ref;
}
What is the maximum recommended depth for hook composition chains?
  • Keep it shallow: hook A calling hook B is fine; A calling B calling C calling D is a code smell.
  • Deep chains make cleanup propagation and debugging harder.
  • If you need deep composition, consider refactoring into fewer, more focused hooks.