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 can only run in Client Components. If a hook uses browser APIs, add 'use client' to the component that calls it — not to the hook file itself.
// The hook itself has no 'use client' — it is framework-agnostic import { useEffect, useState } from 'react'; export 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; } // The component that calls it gets 'use client' // features/cookbook/components/SearchInput.tsx 'use client'; import { useDebounce } from '@/shared/hooks/useDebounce'; export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) { const [query, setQuery] = useState(''); const debouncedQuery = useDebounce(query, 300); useEffect(() => { onSearch(debouncedQuery); }, [debouncedQuery, onSearch]); return ( <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search recipes..." /> ); }