メインコンテンツまでスキップ

カスタムフック

この章では、ロジックを再利用可能な形で抽出するカスタムフックについて学びます。

この章で学ぶこと

  • カスタムフックとは何か、なぜ作るのか
  • カスタムフックの命名規則とルール
  • 実践的なカスタムフックの作成
  • カスタムフックの設計原則
備考

この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。

カスタムフックとは

カスタムフックは、Reactのフック(useState、useEffectなど)を組み合わせて作る再利用可能な関数です。

警告

フックのルール: カスタムフックを含むすべてのフックは、以下のルールに従う必要があります:

  1. トップレベルでのみ呼び出す: ループ、条件分岐、ネストした関数の中では呼び出せない(例外: React 19 の use API は条件分岐やループの中でも呼べます。前章を参照)
  2. Reactコンポーネントまたはカスタムフックの中でのみ呼び出す: 通常のJavaScript関数では使用できない
備考

これらのルール違反を見逃さないために、eslint-plugin-react-hooksrules-of-hooks(条件分岐内でのフック呼び出しを検出)と exhaustive-depsuseEffect などの依存配列の漏れを検出)を有効にしてください。Vite で作成されたプロジェクトではデフォルトで組み込まれています。また、React Compiler の検出ルール(Compiler が最適化をスキップしたコードの報告など)も現在は eslint-plugin-react-hooks に統合されています。

カスタムフックの命名規則

カスタムフックは必ずuseで始める名前を付けます。

// OK
function useCounter() { /* ... */ }
function useLocalStorage() { /* ... */ }
function useFetch() { /* ... */ }

// NG: useで始まっていない
function counter() { /* ... */ }
function getLocalStorage() { /* ... */ }

基本的なカスタムフック

useCounter

// カスタムフック: use で始まる名前の関数
// 内部でReactフック(useState等)を使用できる
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)

// 操作関数を定義
const increment = () => setCount(prev => prev + 1)
const decrement = () => setCount(prev => prev - 1)
const reset = () => setCount(initialValue)

// 状態と操作関数をオブジェクトで返す
return { count, increment, decrement, reset }
}

// 使用例
function Counter() {
// カスタムフックを呼び出し、返り値を分割代入で受け取る
const { count, increment, decrement, reset } = useCounter(10)

return (
<div>
<p>カウント: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>リセット</button>
</div>
)
}

useToggle

// boolean値の切り替えに特化したカスタムフック
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)

const toggle = () => setValue(prev => !prev) // true/falseを反転
const setTrue = () => setValue(true) // 明示的にtrueに
const setFalse = () => setValue(false) // 明示的にfalseに

return { value, toggle, setTrue, setFalse }
}

// 使用例
function Modal() {
// { value: isOpen } で value を isOpen という名前で受け取る
const { value: isOpen, toggle, setFalse } = useToggle()

return (
<>
<button onClick={toggle}>モーダルを開く</button>
{isOpen && (
<div className="modal">
<p>モーダルの内容</p>
<button onClick={setFalse}>閉じる</button>
</div>
)}
</>
)
}

試してみよう: useToggle

boolean値の切り替えを行うカスタムフックを作成してみましょう。togglesetTruesetFalse関数を返すようにしてください。

ヒント
  1. useStateでboolean値を管理
  2. toggleprev => !prevを使って反転
  3. setTrue/setFalseで明示的に値を設定
  4. オブジェクトで返すと分割代入で名前変更が可能
回答と解説
import { useState } from 'react'
import './App.css'

function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)

const toggle = () => setValue(prev => !prev)
const setTrue = () => setValue(true)
const setFalse = () => setValue(false)

return { value, toggle, setTrue, setFalse }
}

function Modal() {
const { value: isOpen, toggle, setFalse } = useToggle()

return (
<>
<button onClick={toggle}>モーダルを開く</button>
{isOpen && (
<div style={{ padding: '20px', border: '1px solid #ccc', marginTop: '10px' }}>
<p>モーダルの内容</p>
<button onClick={setFalse}>閉じる</button>
</div>
)}
</>
)
}

export default function App() {
return <Modal />
}

useToggleは最もシンプルなカスタムフックの例です。toggleで反転、setTrue/setFalseで明示的に値を設定できます。{ value: isOpen, ... }のように分割代入で名前を変更できます。

useInput

function useInput(initialValue = '') {
const [value, setValue] = useState(initialValue)

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}

const reset = () => setValue(initialValue)

return { value, onChange, reset }
}

// 使用例
function Form() {
// reset は <input> のDOM属性ではないため分離し、
// 残りの { value, onChange } だけを spread で渡す
const { reset: resetName, ...nameProps } = useInput('')
const { reset: resetEmail, ...emailProps } = useInput('')

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log({ name: nameProps.value, email: emailProps.value })
resetName()
resetEmail()
}

return (
<form onSubmit={handleSubmit}>
<input {...nameProps} placeholder="名前" />
<input {...emailProps} placeholder="メール" type="email" />
<button type="submit">送信</button>
</form>
)
}

副作用を含むカスタムフック

useLocalStorage

// ジェネリクス<T>: 任意の型に対応
function useLocalStorage<T>(key: string, initialValue: T) {
// useState の初期値に関数を渡す(遅延初期化)
// コンポーネント初回レンダリング時のみ localStorage を読み込み
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})

const setValue = (value: T | ((val: T) => T)) => {
// 関数が渡された場合は実行、そうでなければ値をそのまま使用
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
// 状態更新と同時に localStorage にも保存
localStorage.setItem(key, JSON.stringify(valueToStore))
}

// as const: [値, 関数] のタプル型として返す(配列ではなく)
return [storedValue, setValue] as const
}

// 使用例
function Settings() {
// useState と同じように [値, 更新関数] で受け取れる
const [theme, setTheme] = useLocalStorage('theme', 'light')

const handleToggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light')
}

return (
<button onClick={handleToggleTheme}>
現在: {theme}
</button>
)
}

試してみよう: useLocalStorage

ローカルストレージと同期するカスタムフックを作成してみましょう。ページをリロードしても値が保持されることを確認してください。

ヒント
  1. useStateの初期値に関数を渡し、localStorage.getItemを呼ぶ
  2. setValue関数内でlocalStorage.setItemを呼ぶ
  3. ジェネリクス<T>で任意の型に対応
  4. as constで戻り値をタプル型として返す
回答と解説
import { useState } from 'react'
import './App.css'

function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch {
return initialValue
}
})

const setValue = (value: T | ((val: T) => T)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
localStorage.setItem(key, JSON.stringify(valueToStore))
}

return [storedValue, setValue] as const
}

function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light')

const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light')
}

return (
<div style={{
padding: '20px',
background: theme === 'dark' ? '#333' : '#fff',
color: theme === 'dark' ? '#fff' : '#333'
}}>
<p>現在のテーマ: {theme}</p>
<button onClick={toggleTheme}>テーマ切り替え</button>
<p>(ページをリロードしても保持されます)</p>
</div>
)
}

export default function App() {
return <ThemeToggle />
}

ジェネリクス<T>を使って任意の型に対応しています。useStateの初期値に関数を渡すことで、コンポーネント初回レンダリング時のみlocalStorageを読み込みます。as constで戻り値をタプル型として返すのがポイントです。

useDebounce

// デバウンス: 連続した入力の最後の値だけを処理
// value が変更されてから delay ミリ秒後に debouncedValue を更新
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
// delay 後に値を更新するタイマーを設定
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)

// クリーンアップ: 次の value が来たら前のタイマーをキャンセル
return () => clearTimeout(timer)
}, [value, delay])

return debouncedValue
}

// 使用例
function SearchInput() {
const [query, setQuery] = useState('')
// 500ms 入力がなければ debouncedQuery が更新される
const debouncedQuery = useDebounce(query, 500)

const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}

// debouncedQuery が変わったときだけAPI呼び出し
// → 入力中は呼ばれない(パフォーマンス向上)
useEffect(() => {
if (debouncedQuery) {
console.log('検索:', debouncedQuery)
// API呼び出しなど
}
}, [debouncedQuery])

return (
<input
value={query}
onChange={handleQueryChange}
placeholder="検索..."
/>
)
}

試してみよう: useDebounce

入力値をデバウンスするカスタムフックを作成し、検索入力に使用してみましょう。

ヒント
  1. useStateでデバウンス後の値を管理
  2. useEffect内でsetTimeoutを設定
  3. クリーンアップでclearTimeoutを呼ぶ
  4. 依存配列にvaluedelayを指定
回答と解説
import { useState, useEffect } from 'react'
import './App.css'

function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)

return () => clearTimeout(timer)
}, [value, delay])

return debouncedValue
}

function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 500)
const [results, setResults] = useState<string[]>([])

const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value)
}

useEffect(() => {
if (debouncedQuery) {
// 実際のアプリではAPIを呼び出す
console.log('検索実行:', debouncedQuery)
setResults([
`${debouncedQuery}の検索結果1`,
`${debouncedQuery}の検索結果2`,
`${debouncedQuery}の検索結果3`
])
} else {
setResults([])
}
}, [debouncedQuery])

return (
<div>
<input
value={query}
onChange={handleQueryChange}
placeholder="検索..."
/>
<p>入力値: {query}</p>
<p>デバウンス後: {debouncedQuery}</p>
<ul>
{results.map((result, index) => (
<li key={index}>{result}</li>
))}
</ul>
</div>
)
}

export default function App() {
return <SearchInput />
}

デバウンスは、連続した入力のうち最後の値だけを処理する技術です。useEffectのクリーンアップでclearTimeoutすることで、delayミリ秒以内に新しい値が来たら前のタイマーをキャンセルします。API呼び出しの回数を減らし、パフォーマンスを向上させます。

useMediaQuery

// メディアクエリの状態を監視するカスタムフック
function useMediaQuery(query: string): boolean {
// 初期値: 現在のメディアクエリの結果
// SSR(Node.js 上での実行)では window が存在しないためガードする
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
})

useEffect(() => {
// matchMedia でメディアクエリオブジェクトを取得
const mediaQuery = window.matchMedia(query)
// マウント後にクライアントでの結果をもう一度反映(SSR時の初期値とのズレ解消)
setMatches(mediaQuery.matches)
// 画面サイズが変わったときのハンドラ
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)

// 変更を監視
mediaQuery.addEventListener('change', handler)
// クリーンアップ: リスナーを解除
return () => mediaQuery.removeEventListener('change', handler)
}, [query])

return matches // true/false を返す
}
備考

window.matchMedia はブラウザ専用の API です。Next.js のように SSR(サーバーサイドレンダリング)を行う環境では、このコードが Node.js 上でも実行されるため、typeof window === 'undefined' で必ずガードします。Vite だけの SPA で使う限り window は常に存在するので、ガードを省いても動きますが、後で SSR 環境に移しても壊れないよう癖をつけておくと安心です。

上記は useEffect を使った実装ですが、より堅牢な選択肢として useSyncExternalStore があります。複数のコンポーネントで同じメディアクエリを購読する場合や、React の並行レンダリングと整合を取りたい場合は、そちらの利用も検討してください。

import { useCallback, useSyncExternalStore } from 'react'

function useMediaQuerySync(query: string): boolean {
const getSnapshot = () => window.matchMedia(query).matches
const getServerSnapshot = () => false // SSR 時のフォールバック
// subscribe は useCallback でメモ化する
// (毎レンダリングで新しい関数になると、React がそのたびに再購読してしまう)
const subscribe = useCallback((callback: () => void) => {
const mq = window.matchMedia(query)
mq.addEventListener('change', callback)
return () => mq.removeEventListener('change', callback)
}, [query])
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}

使用例

function ResponsiveComponent() {
// 768px以下ならtrue、それ以上ならfalse
const isMobile = useMediaQuery('(max-width: 768px)')

return (
<div>
{isMobile ? (
<MobileLayout />
) : (
<DesktopLayout />
)}
</div>
)
}

カスタムフックの設計原則

1. 単一責任

1つのカスタムフックは1つの責任を持つようにします。

// OK: 単一の責任
function useWindowSize() { /* ... */ }
function useOnlineStatus() { /* ... */ }

// NG: 複数の責任が混在
function useWindowAndOnline() { /* ... */ }

2. 適切な抽象化レベル

共通のパターンを抽出し、再利用しやすい形にします。

// 汎用的なフェッチフック
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)

useEffect(() => {
let ignore = false

async function fetchData() {
try {
const response = await fetch(url)
const json = await response.json()
if (!ignore) setData(json)
} catch (e) {
if (!ignore) setError(e as Error)
} finally {
if (!ignore) setLoading(false)
}
}

fetchData()
return () => { ignore = true }
}, [url])

return { data, loading, error }
}

3. TypeScriptで型安全に

ジェネリクスを活用して、型安全なカスタムフックを作成します。

function useArray<T>(initialValue: T[]) {
const [array, setArray] = useState(initialValue)

const push = (item: T) => setArray(prev => [...prev, item])
const remove = (index: number) => setArray(prev => prev.filter((_, i) => i !== index))
const clear = () => setArray([])

return { array, push, remove, clear, setArray }
}

カスタムフックのテスト

ロジックをカスタムフックに切り出す最大のメリットはテストしやすさです。コンポーネントから独立しているため、UI のレンダリングを経由せずにロジックの振る舞いだけを確認できます。

現在の React プロジェクトでは、Vitest + React Testing Library の組み合わせがデファクトスタンダードです。Vite で作成したプロジェクトには Vitest がそのまま組み込めます。

npm install -D vitest @testing-library/react @testing-library/dom happy-dom

renderHook でカスタムフックを単独でテストできます。以下は、この章の最初に作成した useCountersrc/hooks/useCounter.ts に切り出して export を付けた前提のテストコードです。

// src/hooks/useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCounter } from './useCounter'

describe('useCounter', () => {
it('初期値を返す', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})

it('increment でカウントが 1 増える', () => {
const { result } = renderHook(() => useCounter(0))
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
})
備考

act(() => ...) は、state の更新と再レンダリングをまとめて反映させるためのユーティリティです。これを挟むことで、テスト側でも React の更新タイミングと同期できます。コンポーネントのテストでは @testing-library/reactrender / screen / userEvent を組み合わせて、ユーザー視点の挙動を検証するのが基本です。

テスト戦略をさらに深めたい場合は、公式ドキュメント VitestTesting Library を参照してください。

まとめ

  • カスタムフックはuseで始める名前を付ける
  • useState、useEffectなどを組み合わせてロジックを抽出
  • 単一責任の原則を守る
  • TypeScriptのジェネリクスで型安全に
  • 共通パターンを見つけたら抽出を検討
  • Vitest + React Testing Library の renderHook で単独テスト可能

次の章では、CSSスタイリング手法の比較について学びます。

次に読む

UI・スタイリング へ進みます。スタイリング手法の概観、Tailwind CSS、Portal / Modal の実装パターンを学びます。