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()))data-ai
license: Apache-2.0 NOT for unrelated tasks outside this domain.
development
Use when designing caching strategies (cache-aside, write-through, write-behind), implementing distributed locks, building rate limiters, leaderboards, real-time streams (XADD/consumer groups), pub/sub, or tuning eviction policies. Triggers: thundering-herd on cache miss, dogpile on key expiry, Redlock vs SET-NX-PX choice, sliding-window rate limiter, hot-key on a single cluster slot, big-key blowup, MULTI/EXEC across slots, KEYS in production. NOT for Redis Cluster operations/admin (different domain), embedded KV (SQLite, leveldb), in-process LRU caches, or Memcached.
tools
Drawing the `'use client'` boundary correctly in React Server Components apps (Next.js App Router, RSC frameworks) — leaf-pushing, slot composition, serialization rules, and environment poisoning prevention. Grounded in react.dev and Next.js 16 docs.
development
Use when designing rate limiting for an API, choosing between token bucket / sliding window / leaky bucket / fixed window, implementing it in Redis, deciding edge (Cloudflare/Upstash) vs origin enforcement, sizing per-user vs per-IP vs per-endpoint quotas, returning the right 429 response with Retry-After, or fixing the boundary-burst bug in fixed-window limiters. Triggers: 429 too many requests, INCR + EXPIRE, ZADD + ZREMRANGEBYSCORE + ZCARD, X-RateLimit-Remaining header, Cloudflare WAF rate limiting rules, Upstash @upstash/ratelimit, leaky bucket shaping vs policing, distributed rate limiter consistency. NOT for DDoS mitigation specifically (different scale), CAPTCHA / bot management, full WAF design, or per-user quota billing.