React: カスタムフック
最終更新:2026-10-26
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(トグルスイッチ)
▶ 試してみよう(2) フック 2: useDebounce(ジッター防止)
▶ 試してみよう(3) フック 3: useWindowSize (ウィンドウサイズ)
▶ 試してみよう(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>
)
}
出力:
TEXT
状態:query。コンポーネント:useDebounce
5. カスタムフックの再利用に関する原則
| 原則 | 説明 | 悪い例 | 良い例 |
|---|---|---|---|
| 単一責任の原則 | フックは1つのことだけを行う | useUserAndTheme() |
useUser() + useTheme() |
| 柔軟なパラメータ | 妥当なデフォルト値を設定する | useFetch('/api/data') ハードコーディングされたURL |
useFetch(url) パラメータ化されたURL |
| 返される値 | 「読み込み中」、「エラー」、「データ」の3つの状態を返す | useFetch() データのみを返す |
useFetch() { data, loading, error } を返す |
| リソースのクリーンアップ | コンポーネントがアンマウントされた際の副作用をクリーンアップする | タイマーやイベントリスナーはクリーンアップしない | useEffect でクリーンアップを返す |
| 組み合わせ可能 | 小さなフックを組み合わせて、より大きなフックにできる | useComplexData() 100行 |
useFetch() と useDebounce() の組み合わせ |
▶ サンプル:複数の小さなフックを組み合わせる
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
出力:
TEXT
カスタムフックは { data, loading, error } を返す。使い方:const { data, loading } = useFetch("/api/users")。マウント時に自動でリクエストを送信。
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>
)
}
出力:
TEXT
状態:query(setter:setQuery)
期待される出力:初回読み込み時に8枚の画像が表示される画像ギャラリー。画面の一番下までスクロールすると自動的にさらに画像が読み込まれ、画像をクリックするとプレビューが開きます(キーボード操作に対応)。プレビュー領域の外側をクリックすると、プレビューが閉じます。
▶ サンプル: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>
)
}
出力:
TEXT
useClipboard フック:クリックでテキストをコピー → "Copied!" のフィードバック → タイムアウト後に自動リセット。navigator.clipboard API をラップ
出力:
TEXT
useOnlineStatus:"Online"(緑)/ "Offline"(赤)。navigator.onLine イベントを監視
▶ サンプル:useOnlineStatus フック — ネットワーク状態の検出
▶ 試してみよう出力:
TEXT
useOnlineStatus フック:"Online"(緑)または "Offline"(赤)を表示。navigator.onLine と online/offline イベントを監視
出力:
TEXT
状態:isOnline(setter:setIsOnline)。useEffect が副作用を管理
▶ サンプル:useLocalStorage フック — 状態の永続化
▶ 試してみよう出力:
TEXT
useLocalStorage("theme", "light") → 再読み込み後も保持される。切り替え → "dark"。ページを開き直すと保存済みの値が復元される。
出力:
TEXT
状態:timeleft、isrunning。ボタン:start、pause、resume。useeffect を使用
▶ サンプル:useCountdown フック — カウントダウン
▶ 試してみよう出力:
TEXT
CountdownTimer コンポーネントの状態は:seconds、autostart。インタラクティブな UI をレンダリング。
❓ よくある質問
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フックを作成してください。自動再接続とハートビート検出に対応している必要があります。