Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
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 \};
\}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;
\}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;
\}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;
\}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];
\}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];
\}export function usePrevious<T>(value: T): T | undefined \{
const ref = useRef<T | undefined>(undefined);
useEffect(() => \{
ref.current = value;
\});
return ref.current;
\}Extract a custom hook when:
Do NOT extract a hook when:
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);
\});
\});
## 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();
});
});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.
This skill provides:
mkdir -p .claude/skills/custom-hooks-crafting
# Paste the Recipe content into .claude/skills/custom-hooks-crafting/SKILL.mduseState(() => expensiveComputation()) is different from useState(expensiveComputation()).use() in React 19 can be.| 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) |
useState + useEffect pattern appears in 2+ components.useState.use prefix to apply the Rules of Hooks (no conditional calls, no calls in loops).[value, setter] (like useState).{ value, loading, error }.function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// T is inferred from initialValue
}typeof window !== "undefined" before accessing browser APIs like localStorage, navigator, or matchMedia.false for useMediaQuery).useCallback, the function reference changes on every render.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();
});useState(() => expensiveComputation()) runs the function only once on initial render (lazy initializer).useState(expensiveComputation()) runs the function on every render, wasting computation.handlerRef.current = handler.handlerRef.current(...) instead of handler(...).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;
}Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥