Custom Hooks
The boundary between logic and rendering. When to extract a hook, what the rules are, and how to avoid the most common mistake.
Principle
A custom hook is not just a function that starts with 'use' — it is a boundary between logic and rendering. The component handles what the user sees. The hook handles how data gets there. When you separate these two concerns, components become easier to read, logic becomes easier to test, and both become easier to change independently.
If you would write a unit test for the logic, it belongs in a hook. If you would write a component test for it, it belongs in the JSX.
Rules
- check_circleName starts with 'use'This is not just a convention — React uses it to enforce the rules of hooks. A function starting with 'use' is treated as a hook.
- check_circleExtract when logic repeatsIf the same stateful logic appears in two components, extract it to a hook. Do not copy-paste hooks between components.
- check_circleExtract when logic is complexIf a component has more than one useEffect, multiple useState calls, or complex derived state — that logic belongs in a hook.
- check_circleHooks are not global stateEach component that calls a hook gets its own isolated instance. Hooks do not share state between components unless backed by a store or context.
Pattern
// ❌ Before — logic mixed into component function SearchInput() { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); useEffect(() => { const timer = setTimeout(() => { setDebouncedQuery(query); }, 300); return () => clearTimeout(timer); }, [query]); // Component is doing too much return <input value={query} onChange={e => setQuery(e.target.value)} />; } // ✅ After — logic extracted to a hook function useDebounce<T>(value: T, delay: number): T { const [debounced, setDebounced] = useState<T>(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } // Component is now focused on rendering only function SearchInput() { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); return <input value={query} onChange={e => setQuery(e.target.value)} />; }
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js, hooks that use browser APIs need 'use client'. Feature-specific hooks live in the feature folder, shared hooks in shared/. See the starter: github.com/sindev08/react-principles-nextjs → src/features/users/hooks/
'use client'; import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/query-keys'; import { usersService } from '@/lib/services/users'; // Feature hook — encapsulates query key, service call, and caching // Components just call useUsers() and get typed data back export function useUsers(params?: { limit?: number; skip?: number }) { return useQuery({ queryKey: queryKeys.users.list(params ?? {}), queryFn: () => usersService.getAll(params), }); } // Also in the starter: // features/users/hooks/useCreateUser.ts — mutation with cache invalidation // shared/hooks/useDebounce.ts — generic debounce (logic extraction) // shared/hooks/useMediaQuery.ts — browser API sync (useSyncExternalStore) // shared/hooks/useLocalStorage.ts — storage sync with cross-tab support
View custom hooks in starter
View the real implementation in react-principles-nextjs