カスタムフック
1. 学習内容
- カスタムフックの命名規則とルール
- カスタムフックのパラメータと戻り値の設計
- 実用的なカスタムフック 6選
- カスタムフックの再利用に関する原則
2. フォーム再利用要件の経緯
カスタムフック用の再利用パターン
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) 課題:5ページにわたって同じロジックが繰り返されている
ボブは、5つの異なるページで「ローカルストレージからユーザー設定を取得する」必要があります:
JSX
// 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!)
}
問題点:localStorage 読み書きのロジックが5つのページにわたり5回もコピー&ペーストされています。「ストレージイベントを監視する」という機能を追加したい場合、5つのファイルを修正しなければなりません。
(2) カスタムフックの解決策
JSX
// ---- 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
}
メリット:20行のロジック → 1行のコード。ロジックを変更する際は、useLocalStorageという1つのファイルのみを変更すれば済みます。
3. カスタムフックのルール
| 規則 | 説明 | 違反時の措置 |
|---|---|---|
use から始める |
React の依存関係名によるフックの特定 | リンターはこのルールをチェックしません。条件付き呼び出しではエラーは発生しません |
| 最上位レベルでのみ呼び出す | ループ、条件分岐、またはネストされたブロック内では呼び出さない | フックチェーンの不整合、状態の混乱 |
| 関数コンポーネントまたはカスタムフック内でのみ呼び出してください | 通常の関数やクラスコンポーネント内では使用しないでください | ランタイムエラー:無効なフック呼び出し |
| JSXではなくデータや関数を返す | フックはロジックを返し、コンポーネントはUIを返す | 関心の分離に反し、再利用が困難になる |
| 副作用のクリーンアップ | useEffect でクリーンアップ関数を返す | メモリリーク、イベントリスナーの蓄積 |
(1) 命名規則
JSX
// ✅ 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) 戻り値の設計
JSX
// 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) 内部では任意のフックを使用できる
JSX
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つの実用的なカスタムフック
| フック | 関数 | 内部で使用されるフック | 戻り値 |
|---|---|---|---|
useToggle |
ブールスイッチ(トグル) | useState, useCallback |
{ value, toggle, setTrue, setFalse } |
useDebounce |
手ぶれ補正入力 | useState, useEffect |
debouncedValue |
useWindowSize |
ウィンドウサイズの監視 | useState, useEffect |
{ width, height } |
useFetch |
データ要求(トライステート) | useState, useEffect, useCallback |
{ data, loading, error, refetch } |
useIntersectionObserver |
可視性検出 | useState, useEffect |
isIntersecting |
useMediaQuery |
レスポンシブ・ブレークポイント | useState, useEffect |
matches |
(1) フック 1: useToggle(トグルスイッチ)
JSX
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) フック 2: useDebounce(ジッター防止)
JSX
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) フック 3: useWindowSize (ウィンドウサイズ)
JSX
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) フック 4: useFetch(データリクエスト)
JSX
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) フック 5: useIntersectionObserver(表示状態の検出)
JSX
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) フック 6: useMediaQuery(レスポンシブ・ブレークポイント)
JSX
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. カスタムフックの再利用に関する原則
| 原則 | 説明 | 悪い例 | 良い例 |
|---|---|---|---|
| 単一責任の原則 | フックは1つのことだけを行う | useUserAndTheme() |
useUser() + useTheme() |
| 柔軟なパラメータ | 妥当なデフォルト値を設定する | useFetch('/api/data') ハードコーディングされたURL |
useFetch(url) パラメータ化されたURL |
| 返される値 | 「読み込み中」、「エラー」、「データ」の3つの状態を返す | useFetch() データのみを返す |
useFetch() { data, loading, error } を返す |
| リソースのクリーンアップ | コンポーネントがアンマウントされた際の副作用をクリーンアップする | タイマーやイベントリスナーはクリーンアップしない | useEffect でクリーンアップを返す |
| 組み合わせ可能 | 小さなフックを組み合わせて、より大きなフックにできる | useComplexData() 100行 |
useFetch() と useDebounce() の組み合わせ |
(1) ▶ サンプル:複数の小さなフックを組み合わせる
JSX
// ============================================
// 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
6. 完全な例:画像ギャラリー(複数のカスタムフックの組み合わせ)
JSX
// ============================================
// 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>
)
}
期待される出力:初回読み込み時に8枚の画像が表示される画像ギャラリー。画面の一番下までスクロールすると自動的にさらに画像が読み込まれ、画像をクリックするとプレビューが開きます(キーボード操作に対応)。プレビュー領域の外側をクリックすると、プレビューが閉じます。
(1) ▶ サンプル:useClipboard フック — クリップボードへのコピー
JSX
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>
)
}
(2) ▶ サンプル:useOnlineStatus フック — ネットワーク状態の検出
JSX
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>
)
}
(3) ▶ サンプル:useLocalStorage フック — 状態の永続化
JSX
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>
)
}
(4) ▶ サンプル:useCountdown フック — カウントダウン
JSX
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>
)
}
❓ よくある質問
Q カスタムフックと通常の関数の違いは何ですか?
A カスタムフック内では React Hooks(
useState や useEffect など)を使用できますが、通常の関数内では使用できません。したがって、「フックを必要とする再利用可能なロジック」にはカスタムフックを使用し、「純粋な計算ロジック」には通常の関数で十分です。例えば、formatDate()は通常の関数を使用していますが、useWindowSize()はカスタムフックを使用しています。Q カスタムフックのテストはどのように行いますか?
A
@testing-library/react-hooks ライブラリを使用します。カスタムフックのテストは、コンポーネントのテストとまったく同じです。フックをレンダリングし、戻り値を確認し、更新をトリガーして、変更内容を確認します。純粋に論理的なフックの場合、テストは非常に簡単です。Q いつロジックをカスタムフックに抽出すればよいですか?
A 2つ以上のコンポーネントで同じロジックが見られる場合です。(そのロジックが実際に再利用されることが確実になる前など)早すぎる段階で抽出するのは避けましょう。かといって、(5回もコピー&ペーストしてしまうまで)あまり長く待つのもよくありません。役立つ目安として、「3回ルール」があります。同じロジックが3回以上現れる場合は、必ずカスタムフックに抽出する必要があります。
Q カスタムフックで
useEffect を使用できますか?A はい、可能です。これは非常に一般的な使い方です。カスタムフックでは、あらゆる組み込みフック(
useState、useEffect、useRef、useContext など)を使用できます。例えば、useFetchフックは、データ、読み込み、およびエラー状態の管理に内部でuseStateを使用し、マウント時のデータ取得にuseEffectを、AbortControllerの保存にuseRefを使用しています。これこそがカスタムフックの価値であり、複数のフックを1つの再利用可能なロジック単位に統合できる点にあります。📖 まとめ
- カスタムフックとは、
useで始まる関数のことであり、その中では組み込みのフックをすべて使用できます。 - 戻り値は、配列(例:
useState)、オブジェクト(複数の戻り値)、または単一の値のいずれかになります - 6つの実用的なフック:useToggle、useDebounce、useWindowSize、useFetch、useIntersectionObserver、useMediaQuery
- 「3回の法則」に従う――あるコードが論理的に3回以上再利用される場合は、それをフックとして抽出する
- 単一責任の原則に従い、小さなフックを組み合わせることで、より大きなフックを形成することができます
📝 練習問題
- 基本問題(難易度 ⭐):設定可能な初期値を持つ、
useCounterを引数として受け取り、{ count, increment, decrement, reset }を返すフックを作成してください。 - 上級問題(難易度 ⭐⭐):
navigator.clipboardAPI をラップし、{ copied, copy(text) }を返すuseClipboardフックを作成してください。また、テキストをクリップボードにコピーする機能もサポートしてください。 - 課題(難易度:⭐⭐⭐):WebSocket接続をラップし、
{ data, isConnected, send(message), reconnect() }を返すuseWebSocketフックを作成してください。自動再接続とハートビート検出に対応している必要があります。



