React: Custom Hooks
Last updated: 2026-08-26
1. What You'll Learn
- Naming conventions and rules for custom hooks
- Designing the Parameters and Return Values of Custom Hooks
- 6 Practical Custom Hooks
- Principles for Reusing Custom Hooks
2. The Story of a Form Reuse Requirement
Reuse Patterns for Custom Hooks
flowchart TD
A[Repetitive Logic] --> B[Extract as useXxx]
B --> C[ComponentsA useXxx]
B --> D[ComponentsB useXxx]
B --> E[ComponentsC useXxx]
F[useXxxInside] --> G[useState]
F --> H[useEffect]
F --> I[useRef]
F --> J[useContext]
K[The Rule of Three] --> L{Number of logical repetitions?}
L -->|1 time| M[No extraction required]
L -->|2 times| N[Consider extracting]
L -->|3+ times| O[Must be extracted ✅]
style B fill:#e8f5e9,stroke:#2e7d32
style O fill:#c8e6c9,stroke:#2e7d32
(1) Pain Point: The same logic is repeated across 5 pages
Bob needs to "retrieve user preferences from local storage" on five different pages:
// Page A:Settings Page
function SettingsPage() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page A specifically UI
}
// Page B:Dashboard
function Dashboard() {
const [prefs, setPrefs] = useState(() => {
const saved = localStorage.getItem('preferences')
return saved ? JSON.parse(saved) : { theme: 'light', lang: 'zh' }
})
useEffect(() => {
localStorage.setItem('preferences', JSON.stringify(prefs))
}, [prefs])
// ... Page B specifically UI(Exactly the same logic!)
}
Issue: localStorage The read/write logic has been copied and pasted five times across five pages. If we want to add a feature to "listen for storage events," we’d have to modify five files.
(2) Solutions for Custom Hooks
// ---- Custom Hook:Packaging localStorage Reading and Writing ----
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved ? JSON.parse(saved) : initialValue
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch (e) {
console.error('Save to localStorage Failure:', e)
}
}, [key, value])
return [value, setValue]
}
// ---- Use this consistently on all pages ----
function SettingsPage() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
function Dashboard() {
const [prefs, setPrefs] = useLocalStorage('preferences', {
theme: 'light', lang: 'zh'
})
// ... Specifically UI
}
Benefits: 20 lines of logic → 1 line of code. When modifying the logic, you only need to change a single file: useLocalStorage.
3. Rules for Custom Hooks
| Rule | Description | Consequences of Violation |
|---|---|---|
Starting with use |
Identifying Hooks by React Dependency Names | Linter does not check this rule; conditional calls will not trigger errors |
| Call only at the top level | Do not call within loops, conditionals, or nested blocks | Hook chain misalignment, state confusion |
| Call only within function components or custom hooks | Do not use within regular functions or class components | Runtime error: Invalid Hook call |
| Returns data/functions, not JSX | Hooks return logic, components return UI | Violates the separation of concerns, making reuse difficult |
| Cleaning Up Side Effects | useEffect return cleanup function | Memory Leaks, Accumulation of Event Listeners |
(1) Naming Conventions
// ✅ It is essential to use Introduction
function useOnlineStatus() { }
function useWindowSize() { }
function useDebounce() { }
// ❌ Not based on use Introduction → React Will not check Hooks Rules
function onlineStatus() { }
function fetchData() { }
(2) Return Value Design
// Method 1:Return an array(like useState)
function useToggle(initial = false) {
const [value, setValue] = useState(initial)
const toggle = useCallback(() => setValue(v => !v), [])
return [value, toggle] // Array,Flexible Deconstruction of Names
}
// Method 2:Return Object(Greater clarity when there are multiple return values)
function useUser() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
return { user, loading, error } // Object,Property Names Are Clear
}
// Method 3:Returns a single value
function useDocumentTitle(title) {
useEffect(() => { document.title = title }, [title])
// No return value required
}
(3) Any hooks can be used internally
function useData(url) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(url)
.then(res => {
if (!res.ok) throw new Error('Request Failed')
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
return { data, loading, error }
}
// Used internally useState + useEffect + useCallback(If necessary)
// Custom Hook You can combine any of the built-in features Hooks
4. 6 Practical Custom Hooks
| Hook | Function | Hooks Used Internally | Return Value |
|---|---|---|---|
useToggle |
Boolean Switch Toggle | useState, useCallback |
{ value, toggle, setTrue, setFalse } |
useDebounce |
Input anti-shake | useState, useEffect |
debouncedValue |
useWindowSize |
Window size monitoring | useState, useEffect |
{ width, height } |
useFetch |
Data Request Tri-state | useState, useEffect, useCallback |
{ data, loading, error, refetch } |
useIntersectionObserver |
Visibility detection | useState, useEffect |
isIntersecting |
useMediaQuery |
Responsive breakpoint | useState, useEffect |
matches |
(1) Hook 1: useToggle (Toggle Switch)
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return { value, toggle, setTrue, setFalse }
}
// Usage
function ModalExample() {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle(false)
return (
<div>
<button onClick={open}>Open the pop-up window</button>
{isOpen && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ backgroundColor: 'white', padding: '24px', borderRadius: '8px' }}>
<h2>Pop-up Title</h2>
<p>Pop-up Content</p>
<button onClick={close}>Close</button>
</div>
</div>
)}
</div>
)
}
(2) Hook 2: useDebounce (anti-jitter)
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// Usage:Search Input Stabilization
function SearchBox() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 500)
// Only at debouncedQuery Request only when there is a change(The user has stopped typing 500ms after )
useEffect(() => {
if (debouncedQuery) {
fetch('/api/search?q=' + debouncedQuery).then(/* ... */)
}
}, [debouncedQuery])
return <input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search..." />
}
(3) Hook 3: useWindowSize (window size)
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
let timeoutId
function handleResize() {
// Image Stabilization:50ms several times inside resize Execute only once
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
setSize({ width: window.innerWidth, height: window.innerHeight })
}, 50)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
clearTimeout(timeoutId)
}
}, [])
return size
}
// Usage
function ResponsiveComponent() {
const { width } = useWindowSize()
return (
<p>
Current window width:{width}px
{width < 768 ? '(Mobile View)' : width < 1024 ? '(Flat View)' : '(Desktop View)'}
</p>
)
}
(4) Hook 4: useFetch (data request)
function useFetch(url, options = {}) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch(url, options)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`)
return res.json()
})
.then(data => { if (!cancelled) { setData(data); setLoading(false) }})
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false) }})
return () => { cancelled = true }
}, [url])
// Resubmit Request
const refetch = useCallback(() => {
setLoading(true)
fetch(url, options)
.then(res => res.json())
.then(data => { setData(data); setLoading(false) })
.catch(err => { setError(err.message); setLoading(false) })
}, [url])
return { data, loading, error, refetch }
}
// Usage
function UserProfile({ userId }) {
const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`)
if (loading) return <p>Loading......</p>
if (error) return <p>Error:{error} <button onClick={refetch}>Retry</button></p>
return <h1>{user.name}</h1>
}
(5) Hook 5: useIntersectionObserver (Visibility Detection)
function useIntersectionObserver(ref, options = {}) {
const [isIntersecting, setIsIntersecting] = useState(false)
useEffect(() => {
if (!ref.current) return
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting)
}, { threshold: options.threshold || 0.1, ...options })
observer.observe(ref.current)
return () => observer.disconnect()
}, [ref, options.threshold])
return isIntersecting
}
// Usage:Lazy Loading of Images
function LazyImage({ src, alt }) {
const imgRef = useRef(null)
const isVisible = useIntersectionObserver(imgRef)
const [loaded, setLoaded] = useState(false)
return (
<div ref={imgRef} style={{ minHeight: '200px', backgroundColor: '#f5f5f5' }}>
{isVisible && (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ width: '100%', opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
/>
)}
</div>
)
}
(6) Hook 6: useMediaQuery (Responsive Breakpoints)
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches)
useEffect(() => {
const mql = window.matchMedia(query)
function handler(e) { setMatches(e.matches) }
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
// Usage
function ResponsiveLayout() {
const isMobile = useMediaQuery('(max-width: 768px)')
const isTablet = useMediaQuery('(min-width: 769px) and (max-width: 1024px)')
const isDesktop = useMediaQuery('(min-width: 1025px)')
return (
<div>
{isMobile && <MobileMenu />}
{isDesktop && <FullSidebar />}
<p>
{isMobile ? '📱 Cell Phone' : isTablet ? '📟 Tablet' : '💻 Computer'}
</p>
</div>
)
}
5. Principles for Reusing Custom Hooks
| Principle | Explanation | Bad Example | Good Example |
|---|---|---|---|
| Single Responsibility | A hook does only one thing | useUserAndTheme() |
useUser() + useTheme() |
| Flexible Parameters | Provide reasonable default values | useFetch('/api/data') Hard-coded URL |
useFetch(url) Parameterized URL |
| Complete return values | Returns three states: loading, error, and data | useFetch() Returns only data |
useFetch() Returns { data, loading, error } |
| Resource Cleanup | Clean up side effects when the component is unmounted | Do not clean up timers/event listeners | useEffect return cleanup |
| Composable | Small hooks can be combined into larger hooks | useComplexData() 100 lines |
useFetch() + useDebounce() combination |
▶ Example: Combining Multiple Small Hooks
Output:
State: query. Component: useDebounce
// ============================================
// Example:Combine multiple small Hook Building Complex Search Features
// ============================================
// 1. Small hook:Input Stabilization
function useDebounce(value, delay) { /* ... */ }
// 2. Small hook:Data Request
function useFetch(url) { /* ... */ }
// 3. Small hook:Local Storage
function useLocalStorage(key, initial) { /* ... */ }
// 4. Combined into a large Hook:Search
function useSearch(defaultQuery = '') {
const [query, setQuery] = useState(defaultQuery)
const [history, setHistory] = useLocalStorage('search_history', [])
const debouncedQuery = useDebounce(query, 500)
const { data, loading, error } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
)
function search(newQuery) {
setQuery(newQuery)
// Save Search History
if (newQuery.trim()) {
setHistory(prev => [newQuery, ...prev.filter(h => h !== newQuery)].slice(0, 10))
}
}
return { query, setQuery: search, results: data, loading, error, history }
}
// useSearch Combined internally useState + useDebounce + useFetch + useLocalStorage
Output:
Custom hook returns { data, loading, error }. Usage: const { data, loading } = useFetch("/api/users"). Auto-fetches on mount.
6. Complete Example: Image Gallery (Combining Multiple Custom Hooks)
// ============================================
// Complete Example:Photo Gallery(Combination 3 Custom Hook)
// ============================================
import { useState, useEffect, useCallback, useRef } from 'react'
// ---- Custom Hook 1:Infinite Scrolling ----
function useInfiniteScroll(callback) {
const sentinelRef = useRef(null)
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) callback()
}, { threshold: 0.1 })
observer.observe(sentinel)
return () => observer.disconnect()
}, [callback])
return sentinelRef
}
// ---- Custom Hook 2:Keyboard Navigation ----
function useKeyPress(targetKey, callback) {
useEffect(() => {
function handler(e) {
if (e.key === targetKey) callback(e)
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [targetKey, callback])
}
// ---- Custom Hook 3:Click here ----
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
function handler(e) {
if (ref.current && !ref.current.contains(e.target)) {
callback()
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [callback])
return ref
}
// ---- Image Gallery Component ----
function ImageGallery() {
const [images, setImages] = useState([])
const [page, setPage] = useState(1)
const [selected, setSelected] = useState(null)
const [loading, setLoading] = useState(false)
// Loading image
const loadMore = useCallback(() => {
if (loading) return
setLoading(true)
const newImages = Array.from({ length: 8 }, (_, i) => ({
id: images.length + i + 1,
url: `https://picsum.photos/seed/${images.length + i + 1}/300/200`,
title: `Image ${images.length + i + 1}`
}))
setTimeout(() => {
setImages(prev => [...prev, ...newImages])
setPage(p => p + 1)
setLoading(false)
}, 800)
}, [loading, images.length])
// First Load
useEffect(() => { loadMore() }, [])
// Infinite Scroll: Load More
const sentinelRef = useInfiniteScroll(loadMore)
// Click here to close the preview
const previewRef = useClickOutside(() => setSelected(null))
// Escape Key to close the preview
useKeyPress('Escape', () => setSelected(null))
// Use the left and right arrow keys to switch between images
useKeyPress('ArrowLeft', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx > 0) setSelected(images[idx - 1])
}
})
useKeyPress('ArrowRight', () => {
if (selected) {
const idx = images.findIndex(i => i.id === selected.id)
if (idx < images.length - 1) setSelected(images[idx + 1])
}
})
return (
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
<h2>🖼️ Photo Gallery</h2>
<p style={{ color: '#666', fontSize: '13px' }}>Loaded {images.length} images | Keyboard:← Previous → Next Escape Close</p>
{/* Image Grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px' }}>
{images.map(img => (
<div key={img.id} onClick={() => setSelected(img)}
style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}>
<img src={img.url} alt={img.title}
style={{ width: '100%', height: '150px', objectFit: 'cover', transition: 'transform 0.3s' }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'} />
<p style={{ padding: '8px', margin: 0, fontSize: '13px', textAlign: 'center' }}>{img.title}</p>
</div>
))}
</div>
{/* Load More Triggers */}
<div ref={sentinelRef} style={{ textAlign: 'center', padding: '20px' }}>
{loading ? <p>⏳ Loading......</p> : <p style={{ color: '#999' }}>↓ Scroll to load more</p>}
</div>
{/* Image Preview(Click here to close) */}
{selected && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000
}}>
<div ref={previewRef} style={{ maxWidth: '80vw', maxHeight: '80vh' }}>
<img src={selected.url.replace('300/200', '600/400')} alt={selected.title}
style={{ maxWidth: '100%', maxHeight: '80vh', borderRadius: '8px' }} />
<p style={{ color: 'white', textAlign: 'center', marginTop: '12px' }}>
{selected.title}(Click here to close)
</p>
</div>
</div>
)}
</div>
)
}
Expected Output: An image gallery that loads 8 images on initial load; scrolling to the bottom automatically loads more; clicking an image opens a preview (keyboard navigation supported); clicking outside the preview area closes it.
▶ Example 2: useClipboard Hook—Copy to Clipboard
Output:
State: query (setter: setQuery)
function useClipboard() {
const [copied, setCopied] = useState(false)
async function copy(text) {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
setCopied(false)
}
}
return { copied, copy }
}
function CopyButton({ text }) {
const { copied, copy } = useClipboard()
return (
<button onClick={() => copy(text)}
style={{ padding: '4px 12px', cursor: 'pointer', background: copied ? '#52c41a' : '#f0f0f0', color: copied ? 'white' : '#333', border: 'none', borderRadius: 4 }}>
{copied ? 'Copied!' : 'Copy'}
</button>
)
}
Output:
useClipboard hook: click to copy text → "Copied!" feedback → auto-reset after timeout. Wraps navigator.clipboard API
▶ Example 3: useOnlineStatus Hook—Network Status Detection
Output:
useOnlineStatus: "Online" (green) / "Offline" (red). Listens to navigator.onLine events
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
useEffect(() => {
function handleOnline() { setIsOnline(true) }
function handleOffline() { setIsOnline(false) }
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOffline)
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
}
}, [])
return isOnline
}
function StatusBar() {
const isOnline = useOnlineStatus()
return (
<div style={{ padding: '8px 16px', background: isOnline ? '#f6ffed' : '#fff2f0', borderBottom: `1px solid ${isOnline ? '#b7eb8f' : '#ffccc7'}` }}>
{isOnline ? '🟢 Online' : '🔴 Offline — changes will sync when reconnected'}
</div>
)
}
Output:
useOnlineStatus hook: displays "Online" (green) or "Offline" (red). Listens to navigator.onLine and online/offline events
▶ Example 4: useLocalStorage Hook—Persisting State
Output:
State: isOnline (setter: setIsOnline). useEffect manages side effects
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key)
return saved !== null ? JSON.parse(saved) : initialValue
} catch { return initialValue }
})
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)) }
catch (e) { console.error('localStorage save failed:', e) }
}, [key, value])
return [value, setValue]
}
function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}
style={{
padding: '8px 16px', cursor: 'pointer', borderRadius: 4, border: 'none',
background: theme === 'dark' ? '#333' : '#f0f0f0',
color: theme === 'dark' ? 'white' : '#333',
}}>
{theme === 'dark' ? '🌙 Dark' : '☀️ Light'} (saved)
</button>
)
}
Output:
useLocalStorage("theme", "light") → persists across reloads. Toggle → "dark". Reopening page restores saved value.
▶ Example 5: useCountdown Hook—Countdown
Output:
State: timeleft, isrunning. buttons: start, pause, resume. uses useeffect
function useCountdown(seconds, autostart = false) {
const [timeLeft, setTimeLeft] = useState(seconds)
const [isRunning, setIsRunning] = useState(autostart)
useEffect(() => {
if (!isRunning || timeLeft <= 0) return
const timer = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) { setIsRunning(false); return 0 }
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [isRunning, timeLeft])
function start() { setTimeLeft(seconds); setIsRunning(true) }
function pause() { setIsRunning(false) }
function resume() { setIsRunning(true) }
const minutes = Math.floor(timeLeft / 60)
const secs = timeLeft % 60
const formatted = `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
return { timeLeft, formatted, isRunning, start, pause, resume }
}
function CountdownTimer() {
const { formatted, isRunning, start, pause, resume } = useCountdown(300)
return (
<div style={{ textAlign: 'center', padding: 20 }}>
<p style={{ fontSize: 48, fontFamily: 'monospace', margin: '0 0 16px' }}>{formatted}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
{!isRunning ? <button onClick={start} style={{ padding: '8px 16px', cursor: 'pointer' }}>Start</button>
: <button onClick={pause} style={{ padding: '8px 16px', cursor: 'pointer' }}>Pause</button>}
{isRunning || <button onClick={resume} style={{ padding: '8px 16px', cursor: 'pointer' }}>Resume</button>}
</div>
</div>
)
}
Output:
CountdownTimer component with state: seconds, autostart. Renders interactive UI.
❓ FAQ
useState and useEffect) inside a custom hook, but not inside a regular function. Therefore, you should use a custom hook only for "reusable logic that requires Hooks," while a regular function is sufficient for "pure computational logic." For example, formatDate() uses a regular function, while useWindowSize() uses a custom hook.@testing-library/react-hooks library. Testing custom hooks is just like testing a component—render the hook, verify the return value, trigger an update, and verify the changes. For purely logical hooks, testing is very straightforward.useEffect in a custom Hook?useState, useEffect, useRef, useContext, etc.). For example, the useFetch Hook uses useState internally to manage data, loading, and error states; useEffect to fetch data on mount; and useRef to store the AbortController. This is precisely the value of custom hooks—combining multiple hooks into a single reusable unit of logic.📖 Summary
- A custom hook is a function that begins with
use; any built-in hooks can be used within it. - The return value can be an array (like
useState), an object (multiple return values), or a single value - 6 practical hooks:useToggle、useDebounce、useWindowSize、useFetch、useIntersectionObserver、useMediaQuery
- Follow the "Rule of Three"—if a piece of code is reused logically three or more times, extract it into a hook
- Small hooks can be combined to form larger hooks, following the Single Responsibility Principle
📝 Exercises
- Basic Problem (Difficulty ⭐): Create a
useCounterhook that returns{ count, increment, decrement, reset }, with a configurable initial value. - Advanced Problem (Difficulty ⭐⭐): Create a
useClipboardhook that wraps thenavigator.clipboardAPI and returns{ copied, copy(text) }, with support for copying text to the clipboard. - Challenge (Difficulty: ⭐⭐⭐): Create a
useWebSockethook that wraps a WebSocket connection and returns{ data, isConnected, send(message), reconnect() }, supporting automatic reconnection and heartbeat detection.