第10回
カスタムHookでロジックを再利用する:useFetch・useLocalStorage・useDebounce
Reactのカスタムフックの作り方を解説します。useFetch・useLocalStorage・useDebounceを実装例に、フックの抽出パターン・命名規則・テスト方法まで実践的に学べます。
制作・運営:React Guide 運営者
共通にするのはstateそのものではなくロジック
カスタムHookは use と大文字で始まる名前の関数で、ReactのHookを使って振る舞いをまとめます。同じHookを2つのコンポーネントから呼んでも、stateはそれぞれ別です。値そのものを共有したい場合は親のstateやContextを使います。
ここでは、取得・保存・入力待ちを分けて実装します。以下のコードはそれぞれ独立したHookです。1つの巨大な万能Hookへまとめる必要はありません。
useFetch:URLごとに結果を管理する
src/use-fetch.ts の例です。nullなら取得しません。decode はJSONの形式を確認する関数で、モジュールの外側で定義し、レンダーごとに作り直さないでください。
import { useEffect, useState } from 'react'
type FetchState<T> = {
data: T | null
isLoading: boolean
error: string | null
}
export function useFetch<T>(url: string | null, decode: (value: unknown) => T): FetchState<T> {
const [result, setResult] = useState<FetchState<T> & { url: string | null }>({
url: null, data: null, isLoading: false, error: null,
})
useEffect(() => {
if (url === null) return
const controller = new AbortController()
let active = true
setResult({ url, data: null, isLoading: true, error: null })
async function load() {
try {
const response = await fetch(url!, { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const raw: unknown = await response.json()
const data = decode(raw)
if (active) setResult({ url, data, isLoading: false, error: null })
} catch (cause) {
if (active) setResult({
url, data: null, isLoading: false,
error: cause instanceof Error ? cause.message : String(cause),
})
}
}
void load()
return () => { active = false; controller.abort() }
}, [url, decode])
if (url === null) return { data: null, isLoading: false, error: null }
if (result.url !== url) return { data: null, isLoading: true, error: null }
return result
}URLを記録しておくことで、URLが変わった直後のレンダーでも前のユーザーを新しいユーザーとして表示しません。古い取得が後から終わってもactiveフラグで無視します。
この小さなHookにはキャッシュ、重複リクエストの統合、再試行、同じURLの再取得ボタンはありません。フレームワークの取得機能や専用ライブラリと比べて選んでください。
useDebounce:最後の入力から待つ
src/use-debounce.ts に置きます。短時間に何度変わっても、前のタイマーを解除し、最後の値を待ちます。
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}上の2つを使う検索画面の例です。/api/search?q=... が文字列の配列を返すAPIは別途必要です。Viteの雛型だけにはAPIはありません。
import { useState } from 'react'
import { useFetch } from './use-fetch'
import { useDebounce } from './use-debounce'
function decodeResults(value: unknown): string[] {
if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) {
throw new Error('検索結果の形式が違います')
}
return value
}
export default function App() {
const [query, setQuery] = useState('')
const delayed = useDebounce(query, 300)
const term = delayed.trim()
const url = query.trim() && term ? `/api/search?q=${encodeURIComponent(term)}` : null
const { data, isLoading, error } = useFetch(url, decodeResults)
return (
<main>
<label>検索<input value={query} onChange={e => setQuery(e.target.value)} /></label>
{query !== delayed && <p>入力待ち...</p>}
{isLoading && <p>取得中...</p>}
{error && <p role="alert">{error}</p>}
<ul>{data?.map((text, i) => <li key={i}>{text}</li>)}</ul>
</main>
)
}空文字をfetchへ渡すと現在のページを取得してしまいます。取得しない場合をnullで区別します。上のindex keyは編集状態を持たない文字列の表示だけに限定しています。
useLocalStorage:更新関数に副作用を入れない
同じイベントで setValue(n => n + 1) を2回呼んだら2増えてほしいため、Reactのsetterをそのまま返し、保存はEffectで行います。以下はブラウザだけで描画するアプリ・固定key・JSONで保存できる値に限定した例です。SSRの初期表示や別タブとの同期は扱いません。
import { useEffect, useState } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T, decode: (value: unknown) => T) {
const [value, setValue] = useState<T>(() => {
try {
const raw = localStorage.getItem(key)
return raw === null ? initialValue : decode(JSON.parse(raw))
} catch {
return initialValue
}
})
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// 保存できなくても、この画面内のstate更新は維持する
}
}, [key, value])
return [value, setValue] as const
}keyを変えて別のデータへ切り替える実装ではありません。切り替えが必要なら呼び出し元を key={storageKey} で作り直すなど、再初期化の設計を加えます。保存できない環境では画面を閉じると失われるため、重要な入力を保存する用途では保存エラーの表示も追加してください。
自分で確かめる境界
| 操作 | 期待する結果 |
|---|---|
| 検索を空にする | 現在のHTMLへfetchしない |
| Aの通信中にBを検索 | 遅いAがBを上書きしない |
| HTTP 500・形式が違うJSON | エラーになり、取得中表示が終わる |
| 100msごとに入力する | 最後の入力からdelay経過後に確定 |
| 保存値を関数形式で2回更新 | 2回分が反映される |
同じテーマの教材
useState・useRef・useReducerの型定義:TypeScriptで正しく型をつける
TypeScriptでuseState・useRef・useReducerに型をつける方法を解説。型推論が効くケースと明示が必要なケース、useRefのnull初期値問題まで実例付きで説明します。
【解決】useEffectでasync/awaitを使う正しい方法と注意点
useEffectに直接asyncを付けてはいけない理由と、正しいasync/awaitの書き方を解説。内部関数として定義する方法・クリーンアップとの組み合わせ・競合状態(race condition)の防ぎ方まで実例付きで説明します。
Hooksのルールとよくある間違い:シリーズ総まとめ
ReactのHooksが守るべきルールを解説します。条件分岐・ループ内での呼び出し禁止の理由、ESLint設定、無限ループや古い値参照などよくある間違いとその対処法をシリーズの集大成としてまとめます。