第10回

カスタムHookでロジックを再利用する:useFetch・useLocalStorage・useDebounce

Reactのカスタムフックの作り方を解説します。useFetch・useLocalStorage・useDebounceを実装例に、フックの抽出パターン・命名規則・テスト方法まで実践的に学べます。

·10分で読める

制作・運営: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回分が反映される

React公式:カスタムHook、useStateの更新キュー

#react#hooks#custom-hooks#intermediate