第10回

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

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

·13分で読める
たける
たける 複数のページで同じデータ取得のコードを書いてしまってます。コピペしてたら5箇所になって……。
りこ
りこ カスタムHookに切り出すタイミング。「同じロジックが2箇所以上に登場したら」が目安。
ユナ
ユナ バックエンドでいうと共通サービス層やリポジトリパターンと同じ発想。データアクセスのロジックをコンポーネント(コントローラ)から分離して、専用の場所にまとめる。

カスタムHookとは

カスタムHookは、複数のHookを組み合わせたロジックを関数として切り出したものです。命名は use で始める必要があります。

// カスタムHookの基本形
function useMyHook(param: string) {
  const [state, setState] = useState(null);

  useEffect(() => {
    // ロジック
  }, [param]);

  return state;
}

同じロジックが複数のコンポーネントに登場したら、カスタムHookへの抽出を検討するサインです。

useFetch:データフェッチを抽象化する

type FetchState<T> = {
  data: T | null;
  isLoading: boolean;
  error: string | null;
};

function useFetch<T>(url: string): FetchState<T> {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setIsLoading(true);
        setError(null);

        const response = await fetch(url, { signal: controller.signal });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const json: T = await response.json();
        setData(json);
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') return;
        setError(err instanceof Error ? err.message : '取得失敗');
      } finally {
        setIsLoading(false);
      }
    }

    fetchData();
    return () => controller.abort();
  }, [url]);

  return { data, isLoading, error };
}

// 使い方
type User = { id: number; name: string; email: string };

function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading, error } = useFetch<User>(`/api/users/${userId}`);

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error}</div>;
  if (!user) return null;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

useLocalStorage:localStorageと同期するstate

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

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const valueToStore =
          value instanceof Function ? value(storedValue) : value;
        setStoredValue(valueToStore);
        localStorage.setItem(key, JSON.stringify(valueToStore));
      } catch (err) {
        console.error('localStorage の書き込み失敗:', err);
      }
    },
    [key, storedValue]
  );

  return [storedValue, setValue] as const;
}

// 使い方
function Settings() {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
  const [fontSize, setFontSize] = useLocalStorage<number>('fontSize', 16);

  return (
    <div>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        {theme === 'light' ? 'ダークモードへ' : 'ライトモードへ'}
      </button>
      <input
        type="range" min={12} max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

useDebounce:入力の間引き処理

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

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

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

  return debouncedValue;
}

// 使い方:検索入力を300ms後まで待ってからAPIを叩く
function LiveSearch() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  const { data: results } = useFetch<string[]>(
    debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : ''
  );

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="検索..."
      />
      <ul>
        {results?.map((r, i) => <li key={i}>{r}</li>)}
      </ul>
    </div>
  );
}

useMediaQuery:レスポンシブ対応

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(
    () => window.matchMedia(query).matches
  );

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);

    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

// 使い方
function ResponsiveNav() {
  const isMobile = useMediaQuery('(max-width: 768px)');

  return isMobile ? <MobileNav /> : <DesktopNav />;
}

カスタムHookのテスト

カスタムHookはReact Testing Libraryの renderHook でテストできます。

import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './use-debounce';

describe('useDebounce', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('指定時間後に値が更新される', () => {
    const { result, rerender } = renderHook(
      ({ value }) => useDebounce(value, 300),
      { initialProps: { value: 'hello' } }
    );

    expect(result.current).toBe('hello');

    rerender({ value: 'world' });
    expect(result.current).toBe('hello');  // まだ変わっていない

    act(() => vi.advanceTimersByTime(300));
    expect(result.current).toBe('world');  // 300ms後に更新
  });
});

まとめ

  • カスタムHookは use で始まる関数。複数コンポーネントで同じロジックを使うときに抽出する
  • useFetch でデータ取得ロジックを集約し、AbortControllerでキャンセルも管理する
  • useLocalStorage でlocalStorageと連動したstateを実現する
  • useDebounce で検索入力など頻繁に変化する値を間引く
  • renderHook でカスタムHookを単体テストできる

次の第11回では、React 18から追加されたコンカレント機能 useTransition を学びます。