| license | Apache-2.0 |
| name | react-hook-composer |
| description | 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). |
| allowed-tools | Read,Write,Edit,Bash(npm:*,npx:*) |
| category | Frontend & UI |
| tags | ["react-hooks","custom-hooks","composable-patterns","testing","renderHook"] |
| pairs-with | [{"skill":"vitest-testing-patterns","reason":"Hook testing with renderHook and act() requires testing expertise"},{"skill":"react-performance-optimizer","reason":"Hook dependency arrays and memoization directly affect performance"}] |
React Hook Composer
Design composable, testable custom React hooks with proper effect cleanup, dependency management, and type safety for reusable behavior encapsulation.
Activation Triggers
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.
Quick Start
- Extract when repeated -- if two components share the same
useState + useEffect pattern, extract to a custom hook.
- Name with
use prefix -- useDebounce, useMediaQuery, useLocalStorage. This enables the Rules of Hooks linter.
- Return a consistent interface -- return
[value, setter] tuples for simple state, objects for complex state.
- Clean up effects -- every
useEffect that subscribes, observes, or creates timers must return a cleanup function.
- Test with
renderHook -- use @testing-library/react renderHook + act for isolated hook testing.
Core Capabilities
| 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 |
Architecture Patterns
Pattern 1: Composable Hook with Proper Cleanup
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);
}, [value, delay]);
return debouncedValue;
}
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(() => {
( {
newValue = value ? (prev) : value;
..(key, .(newValue));
newValue;
});
}, [key]);
[storedValue, setValue] ;
}
Pattern 2: Composing Hooks from Other Hooks
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. │
└──────────────────────────────────────────────────────┘
Pattern 3: Testing Custom Hooks
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: });
(result.).();
( vi.());
(result.).();
});
(, {
{ result, rerender } = (
(value, ),
{ : { : } }
);
({ : });
( vi.());
({ : });
( vi.());
(result.).();
( vi.());
(result.).();
});
});
Anti-Patterns
- Missing effect cleanup --
useEffect that adds event listeners, starts intervals, or creates subscriptions without returning a cleanup function causes memory leaks and stale callbacks.
- Object/array literals in dependency arrays --
useEffect(() => {}, [{ key: 'value' }]) fires on every render because a new object is created each time. Memoize with useMemo or depend on primitive values.
- Stale closure from missing dependencies -- omitting a variable from the dependency array captures an old value. Trust
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.
- Hooks with too many responsibilities -- a hook managing form state, validation, submission, and error display should be split into
useFormState, useFormValidation, useFormSubmit.
Quality Checklist