Purpose
You enforce the hook development standards for this project. Every hook in src/hooks/ MUST follow these rules. Apply them when creating new hooks, modifying existing ones, or reviewing hook code.
When to Run
- Creating a new hook in
src/hooks/ - Modifying an existing hook
- Reviewing hook code for quality
- Migrating legacy hooks to the correct pattern
File Structure
Every hook MUST live in its own directory with this exact layout:
src/hooks/
└── use-hook-name/
├── index.ts # Barrel export (named, never export *)
├── use-hook-name.ts # Implementation
├── use-hook-name.types.ts # Types (when needed, never inline complex types)
└── use-hook-name.test.ts # Co-located test
Rules
- Directory name = kebab-case, matches the hook name:
use-favorite-toggle/. Why: consistent naming enables predictable imports and automated tooling. - No standalone files — every hook gets a directory, even simple ones. Why: uniform structure makes it trivial to add types, tests, or co-located files later without restructuring.
- No
export *in barrel — use explicit named exports. Why: explicit exports enable tree-shaking and make the public API visible at a glance.
// index.ts — CORRECT
export { useFavoriteToggle } from './use-favorite-toggle';
export type {
ToggleFavoriteRequest,
ToggleFavoriteResponse,
UseFavoriteToggleReturn,
} from './use-favorite-toggle.types';
// index.ts — WRONG
export * from './use-favorite-toggle';
- Types file — create
use-hook-name.types.tswhen you have interfaces for params, responses, or return types. Keep types inline only for trivial cases (1-2 simple types). 'use client'directive — REQUIRED at the top of every hook that uses React hooks (useState,useEffect,useReducer,useRef,useQuery,useActionState, etc.).
Data Fetching — TanStack Query
NEVER use manual fetch() + useState + useEffect for data fetching. Always use TanStack Query. Prefer queryOptions() factories in src/lib/queries/ for simple queries (reusable in SSR, client, and imperative contexts). Create custom hooks only when adding logic beyond the query itself (composition, derived state, side effects).
For full templates (query, mutation, infinite query, optimistic updates), loading state guide, conditional queries, polling, debounce patterns, and SSR hydration, see references/tanstack-query-patterns.md.
Query Key Factory — queryKeys (MANDATORY)
All query keys MUST come from the factory in src/lib/query-keys.ts. NEVER hardcode query key arrays. Why: centralized keys prevent cache collisions and make invalidation patterns discoverable.
// CORRECT
queryKey: queryKeys.favorites.status(itemId)
// WRONG — hardcoded key
queryKey: ['items', id, 'assignment']
When You Need a New Key
- Add it to
src/lib/query-keys.tsin the appropriate section - Follow the existing pattern:
entity.action(params) - Use
as conston the return - Add corresponding
staleTimesandgcTimesentries if needed
Cache Invalidation Strategies
| Strategy | When | Example |
|---|---|---|
invalidateQueries (broad key) |
Refetch all related data | queryKeys.favorites.lists() — invalidates ALL favorite lists |
invalidateQueries (narrow key) |
Refetch specific item | queryKeys.favorites.itemCount(itemId) |
setQueryData |
You know the new value, update cache directly | setQueryData(queryKeys.favorites.status(id), { isFavorited: true }) |
| Multi-key invalidation | Mutation affects multiple caches | Invalidate lists + count + itemCount in one onSuccess |
setQueryData + invalidateQueries |
Optimistic update + eventual consistency | Set data immediately, invalidate in onSettled to reconcile |
WARNING: Never invalidate queries inside useEffect based on dataUpdatedAt — this creates invalidation loops. Use onSettled callbacks instead.
Stale & GC Times — Centralized Config (MANDATORY)
Never hardcode timing values. Import from src/lib/query-keys.ts:
// CORRECT
import { staleTimes, gcTimes } from '@/lib/query-keys';
staleTime: staleTimes.favorites,
gcTime: gcTimes.favorites,
// WRONG — hardcoded
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
If a domain doesn't have stale/gc times defined yet, add them to the centralized config.
React 19 & React Compiler
This project uses React 19 hooks (useActionState, useOptimistic, use()) and has React Compiler enabled. Do NOT write manual useMemo or useCallback in new hooks — the compiler handles memoization at build time. Why: manual memos add noise and can conflict with compiler optimizations.
For useActionState and useOptimistic templates, use() rules, and compiler edge cases, see references/react19-and-compiler.md.
Hook Composition
Hooks can (and should) compose other hooks from src/hooks/:
export function useEditProfileForm(): UseEditProfileFormReturn {
const { user } = useAuth();
const { profile, isLoading: isProfileLoading } = useUserProfile(user?.id);
const { mutate, isPending } = useUpdateProfileMutation();
// Derive state, combine loading flags, etc.
}
Rules:
- Call hooks in consistent order (React rules of hooks)
- Pass derived values between hooks
- Keep composed hooks under 300 lines; if too large, split the composition
- Each sub-hook should handle one concern (auth, data, mutation)
State Machines with useReducer
For multi-step flows (transfers, wizards), use a typed reducer as a state machine. Define discriminated union types for state and actions, then a pure flowReducer function with switch on action.type. Typed transitions prevent invalid state combinations. The reducer is a pure function — test it directly without renderHook.
type FlowState =
| { step: 'idle' }
| { step: 'confirm'; recipientId: string }
| { step: 'submitting'; recipientId: string }
| { step: 'success'; txHash: string }
| { step: 'error'; error: Error };
type FlowAction =
| { type: 'START'; recipientId: string }
| { type: 'SUBMIT' }
| { type: 'SUCCESS'; txHash: string }
| { type: 'FAIL'; error: Error }
| { type: 'RESET' };
function flowReducer(state: FlowState, action: FlowAction): FlowState { /* switch on action.type */ }
Types Conventions
// use-hook-name.types.ts
export interface UseHookNameParams {
id: string;
enabled?: boolean;
}
export interface HookNameResponse {
success: boolean;
data: SomeData;
error?: string;
}
export interface UseHookNameReturn {
data: SomeData | null;
isLoading: boolean;
error: string | null;
}
Discriminated Unions for Complex Return Types
When a hook has distinct states, use discriminated unions instead of nullable fields. TypeScript narrows correctly at call site:
type UseItemResult =
| { status: 'loading' }
| { status: 'success'; data: Item }
| { status: 'error'; error: Error };
Tuple Returns with as const
For hooks returning [value, setter] pairs, use as const to preserve tuple types instead of (T | Function)[]:
return [value, toggle] as const; // infers [boolean, () => void]
Rules
- No
any— strict TypeScript, always - Explicit return type on the hook function signature
- Separate types file for hooks with 3+ interfaces
- Import with
typekeyword:import type { ... } from './use-hook-name.types' - Don't define unused types — if an interface/type isn't imported anywhere, delete it
Hook Size & Responsibility
| Guideline | Limit |
|---|---|
| Max lines per hook | ~300 lines |
| Single responsibility | One concern per hook |
Max useEffect per hook |
2 (prefer 0-1) |
If a hook exceeds 300 lines or handles multiple concerns, split it:
- Data fetching ->
queryOptions()factory or separate query hook - Filter/form state -> separate state hook
- UI behavior -> separate UI hook
- Compose them in a parent hook or component if needed
Testing
Use renderHook from @testing-library/react with a QueryClientProvider wrapper for query/mutation hooks. Test reducers as pure functions without renderHook.
For full testing templates and patterns, see references/testing-hooks.md.
Shared Utilities — Don't Duplicate
Before writing mapping functions, type converters, or helpers inside a hook:
- Check
src/lib/utils/for existing utilities - Check other hooks for the same function (e.g.,
itemToGridItemexists in 6 hooks — it should be insrc/lib/utils/) - If the function is used by 2+ hooks, extract it to
src/lib/utils/ - Shared interfaces used across hooks go in
src/types/, not duplicated in each hook's types file
Anti-Patterns & Verification
Before finishing any hook, check for common anti-patterns (setState during render, useEffect for fetching, manual memoization, hardcoded query keys, string-based error matching) and run through the verification checklist covering structure, directives, types, and build.
For the full anti-patterns table and verification checklist, see references/anti-patterns-checklist.md.
Examples
Example 1: "Create a hook for fetching user profile"
Claude creates:
src/hooks/use-user-profile/directory withindex.ts,use-user-profile.ts, anduse-user-profile.types.ts- Since this is a simple query, defines a
userProfileOptions(userId)factory insrc/lib/queries/usingqueryOptions() - Adds query key
queryKeys.user.profile(userId)tosrc/lib/query-keys.tswith correspondingstaleTimesandgcTimesentries - The custom hook in
use-user-profile.tswraps the factory only if it adds derived state or composition logic; otherwise, consumers useuseSuspenseQuery(userProfileOptions(id))directly
Example 2: "Add a mutation hook for updating settings"
Claude creates:
src/hooks/use-update-settings/directory withindex.ts,use-update-settings.ts, anduse-update-settings.types.ts- Uses the Mutation Hook Template from
references/tanstack-query-patterns.md - Invalidates related queries in
onSuccess(e.g.,queryKeys.settings.detail(userId)andqueryKeys.settings.lists()) - Types file defines
UpdateSettingsRequest,UpdateSettingsResponse, andUseUpdateSettingsReturn - Barrel export uses explicit named exports
Example 3: "This hook has useState + useEffect for fetching, fix it"
Claude:
- Identifies the
useState+useEffect+fetch()anti-pattern - Refactors to use
useQueryor creates aqueryOptions()factory insrc/lib/queries/if the hook is simple enough - Removes manual loading/error state management (replaced by TanStack Query's
isLoading,error) - Adds query key to
queryKeysfactory (never hardcoded) - Imports centralized
staleTimes/gcTimes - Verifies against the anti-patterns checklist in
references/anti-patterns-checklist.md