skills/react-hook-composer/SKILL.md
Design composable custom React hooks with proper dependency management, testing with renderHook, and reusable patterns. Activate on: custom hook design, useEffect cleanup, hook composition, renderHook testing, hook dependency arrays. NOT for: state management libraries (use state-machine-designer), data fetching hooks (use data-fetching-strategist).
npx skillsauth add curiositech/windags-skills react-hook-composerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Design composable, testable custom React hooks with proper effect cleanup, dependency management, and type safety for reusable behavior encapsulation.
Activate on: custom hook design, useEffect with cleanup, hook composition (hooks calling hooks), renderHook testing, dependency array bugs (stale closures, infinite loops), extracting component logic into reusable hooks.
NOT for: state management library selection (XState, Zustand) -- use state-machine-designer. Data fetching/caching hooks (React Query, SWR) -- use data-fetching-strategist.
useState + useEffect pattern, extract to a custom hook.use prefix -- useDebounce, useMediaQuery, useLocalStorage. This enables the Rules of Hooks linter.[value, setter] tuples for simple state, objects for complex state.useEffect that subscribes, observes, or creates timers must return a cleanup function.renderHook -- use @testing-library/react renderHook + act for isolated hook testing.| Domain | Technologies | Key Patterns |
|--------|-------------|--------------|
| Hook Design | React 19, custom hooks | Single-responsibility, composable |
| Effect Management | useEffect, useLayoutEffect | Cleanup, abort controllers, event listeners |
| Dependency Safety | ESLint react-hooks/exhaustive-deps | Stable refs, updater functions, ref callbacks |
| Testing | renderHook, act, waitFor | Isolated hook testing without components |
| Type Safety | TypeScript generics, discriminated unions | Strongly-typed return values and params |
| Composition | Hooks calling hooks | Building complex behavior from simple hooks |
// hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer); // cleanup on value/delay change
}, [value, delay]);
return debouncedValue;
}
// hooks/useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';
export 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;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const newValue = value instanceof Function ? value(prev) : value;
window.localStorage.setItem(key, JSON.stringify(newValue));
return newValue;
});
}, [key]);
return [storedValue, setValue] as const;
}
// hooks/useSearch.ts -- composed from useDebounce + useFetch
import { useState, useMemo } from 'react';
import { useDebounce } from './useDebounce';
import { useQuery } from '@tanstack/react-query';
export function useSearch<T>(endpoint: string, options?: { debounceMs?: number }) {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, options?.debounceMs ?? 300);
const { data, isLoading, error } = useQuery({
queryKey: [endpoint, debouncedQuery],
queryFn: () => fetch(`${endpoint}?q=${debouncedQuery}`).then(r => r.json()),
enabled: debouncedQuery.length >= 2,
});
const results = useMemo(() => (data as T[]) ?? [], [data]);
return {
query,
setQuery,
results,
isLoading: isLoading && debouncedQuery.length >= 2,
error,
isDebouncing: query !== debouncedQuery,
};
}
┌─ Hook Composition ──────────────────────────────────┐
│ │
│ useSearch (high-level, app-specific) │
│ ├── useDebounce (primitive, reusable) │
│ ├── useQuery (from TanStack Query) │
│ └── useMemo (React built-in) │
│ │
│ useAuth (high-level, app-specific) │
│ ├── useLocalStorage (primitive, reusable) │
│ ├── useCallback (React built-in) │
│ └── useEffect (React built-in) │
│ │
│ Rule: primitives are generic, composites are │
│ app-specific. Test both independently. │
└──────────────────────────────────────────────────────┘
// hooks/__tests__/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from '../useDebounce';
describe('useDebounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('hello', 500));
expect(result.current).toBe('hello');
});
it('debounces value changes', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 500 } }
);
rerender({ value: 'world', delay: 500 });
expect(result.current).toBe('hello'); // not yet updated
act(() => vi.advanceTimersByTime(500));
expect(result.current).toBe('world'); // now updated
});
it('resets timer on rapid changes', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } }
);
rerender({ value: 'ab' });
act(() => vi.advanceTimersByTime(200));
rerender({ value: 'abc' });
act(() => vi.advanceTimersByTime(200));
expect(result.current).toBe('a'); // still original
act(() => vi.advanceTimersByTime(100));
expect(result.current).toBe('abc'); // final value after full delay
});
});
useEffect that adds event listeners, starts intervals, or creates subscriptions without returning a cleanup function causes memory leaks and stale callbacks.useEffect(() => {}, [{ key: 'value' }]) fires on every render because a new object is created each time. Memoize with useMemo or depend on primitive values.react-hooks/exhaustive-deps; if it warns, fix it.useEffect for derived state -- useEffect(() => setFullName(first + last), [first, last]) causes an extra render. Compute derived values directly: const fullName = first + last.useFormState, useFormValidation, useFormSubmit.useEffect with subscriptions/timers returns a cleanup functionreact-hooks/exhaustive-deps ESLint rule enabled with zero warningsrenderHook and act (not mounted in dummy components)any)useEffect for derived state (computed directly in render)useCallback when passed to memoized childrenAbortController used in effects that make fetch requests (prevents race conditions)useState initializer uses lazy function for expensive computations (useState(() => compute()))tools
Building resilient distributed systems with circuit breakers, retries with full-jitter exponential backoff, retry budgets (per-request 3-attempt + per-client 10% ratio per Google SRE), deadline propagation, and the cascading-failure math (4 layers × 3 retries = 64x amplification). Grounded in Resilience4j, Microsoft Cloud Patterns, AWS Architecture Blog (Marc Brooker), and Google SRE Book.
testing
Designing HTTP cache headers that work correctly across browsers, CDNs, and shared proxies — `Cache-Control` directives per RFC 9111, `stale-while-revalidate` and `stale-if-error` per RFC 5861, the Vary header for varying responses, and surrogate keys for tag-based purging. Grounded in IETF RFCs and Cloudflare/Fastly docs.
development
Use when designing or fixing a Content Security Policy on a real site, choosing between nonce-based and hash-based CSP, adding strict-dynamic, debugging "Refused to execute inline script" errors, deploying CSP in report-only mode first, configuring report-to / report-uri, or auditing an existing policy for unsafe-inline / unsafe-eval / wildcards. Triggers: "CSP blocks legitimate inline script", strict-dynamic, nonce-{RANDOM}, sha256-{HASH}, object-src none, base-uri none, frame-ancestors, Trusted Types, X-Content-Security-Policy obsolete, report-only vs enforced. NOT for general HTTP security headers (HSTS, COOP/COEP), Trusted Types deep dive, CORS configuration, or building a WAF.
tools
Choosing and operating an HTTP API versioning strategy that doesn't break clients — Stripe's date-based pinned versions, the Deprecation/Sunset header pair (RFC 9745 + RFC 8594), URI vs header vs media-type approaches, and the version-transformer pattern. Grounded in Stripe's published architecture and IETF RFCs.