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:
- Design rules - Naming, return types, parameter conventions
- Creation template - A copy-paste starting point for any new hook
- Recipe library - 6 production-ready hook implementations
- Testing template - Standardized test structure for hooks
- 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.mdGotchas
- 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 fromuseState(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
| Approach | When to Use |
|---|---|
| Plain utility functions | Logic that does not need React hooks |
| Higher-order components | Wrapping behavior around a component (legacy pattern) |
| Render props | Sharing behavior with render control (legacy pattern) |
| Zustand selectors | Shared 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+useEffectpattern 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
useprefix 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](likeuseState). - 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 likelocalStorage,navigator, ormatchMedia. - Return a sensible default during SSR (e.g.,
falseforuseMediaQuery).
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 ofhandler(...). - 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.