Reactのカスタムフック:ロジックを正しく再利用する方法

1. 概要

Reactアプリケーションの複数のコンポーネントが同じロジックを必要とする場合(API呼び出し、localStorageの読み取り、input debounceなど)、stateとuseEffectをコンポーネント間でコピー&ペーストし続ける状況に陥りやすくなります。この記事では、そのロジックをカスタムフックにパッケージ化する方法を説明します。カスタムフックはuseで始まる関数で、既存のhook(useState、useEffect、useRefなど)を活用して再利用可能にします。読了後、useFetch、useLocalStorage、useDebounceを書き、renderHookを使ってテストできるようになります。

2. 問題:コンポーネント間で繰り返されるロジック

APIを呼び出してloading/errorを管理する必要がある2つのコンポーネントがあるとしましょう:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...描画
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...描画
}

CustomerListとOrderListは、URLを除いてほぼ同じロジックを含んでいます。将来的にretryやcancel requestを追加したい場合、あらゆる場所で修正しなければなりません。これがカスタムフックが必要な兆候です。

3. カスタムフックとは

カスタムフックは、通常のJavaScript関数で、名前がuseで始まり、内部で他のReact hookを呼び出します。JSXを返さず、stateと関数を返します。これはコンポーネントに紐付いた「ミニストア」のようなものです。

注意: コンポーネントがカスタムフックを呼び出すたびに、Reactは独立したstateインスタンスを作成します。2つのコンポーネントがuseFetchを使用しても、stateは共有されません。ロジックのみを共有します。

4. 最初のカスタムフック:useFetchを書く

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  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)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

解説:

  • cancelledはrace conditionを避けるために使用します。古いリクエストが完了する前にurlが変わった場合、古い結果がstateに設定されません。

  • Cleanup関数(return () => { cancelled = true })はコンポーネントのunmount時またはurl変更時に実行され、useEffectのライフサイクルに沿っています。

  • Hookは呼び出し元が簡単にdestructureできるように、{ data, loading, error }オブジェクトを返します。

コンポーネントはこのように簡潔になります:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // レンダリングロジックは同様ですが、fetchロジックは重複していません
}

5. 2番目の例:useLocalStorage

stateをlocalStorageと同期するロジックも、よくコピー&ペーストされます:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

解説:

  • useState(() => ...)は遅延初期化子を使用します。関数はマウント時に1回だけ実行され、毎回のレンダリングでlocalStorageを読み込みません。

  • HookはuseStateと同じtupleの[value, setValue]を返すため、ユーザーにはすぐに馴染みやすくなります。

コンポーネントで使用:

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

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      現在のテーマ: {theme}
    </button>
  );
}

5.1. stateが以前の値に依存する場合

以前の値に基づいて更新する必要がある場合(カウンターなど)、stale closureを避けるために関数型アップデートを使用してください:

setValue((prev) => prev + 1); // setValue(value + 1)より安全です

6. 3番目の例:useDebounce

検索inputをdebounceするのは非常に一般的なタスクです:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

解説:

  • valueが変わるたびに新しいsetTimeoutが作成され、古いsetTimeoutはcleanupでclearTimeoutされます。

  • ユーザーがdelayMs以内に入力を停止してからのみ、debouncedが更新されます。

function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. 命名規則とRules of Hooks

カスタムフックは、組み込みhookと同じようにRules of Hooksに従う必要があります:

  1. 名前はuseで始まる必要があります。ESLintプラグインeslint-plugin-react-hooksはこのプレフィックスに基づいてルールをチェックします。useがなければ、これはhookとして認識されません。
  2. hookは関数のトップレベルでのみ呼び出します。if、for、またはコールバック内では呼び出さないでください。
  3. hookはfunction componentまたは別のカスタムhook内でのみ呼び出します。通常の関数では呼び出さないでください。
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

exhaustive-depsを有効にすると、カスタムフック内のuseEffectのdependencyを忘れなくなります。これは「stale data」バグの最も一般的な原因です。

8. カスタムフックのテスト

カスタムフックはJSXを返さないため、コンポーネントのようにレンダリングできません。@testing-library/reactライブラリは、テスト環境でhookを呼び出すためにrenderHookを提供します。

<!-- Reactプロジェクトに適用されません。依存関係はnpmでインストールしてください。以下を参照してください。 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('delayが終わった後にのみ値を更新', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

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

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // まだすぐには変わっていません

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // debounce完了
    vi.useRealTimers();
  });
});

解説:

  • renderHookはhookを呼び出す関数を受け取り、result.currentを返します。これは現在のレンダリング時にhookが返す値です。

  • rerenderは新しいpropsを使用してコンポーネントを再レンダリングをシミュレートします(ユーザーが入力するときにvalueが変わるのと同じです)。

  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...))は実際に待つ必要なく時間を制御でき、テストが高速で安定します。

✓ useDebounce > delayが終わった後にのみ値を更新

9. よくあるエラーと、いつ使用すべきでない場合

よくあるエラー:

  • useEffect内のcleanupを忘れる: useFetchで、cancelledフラグがないと、古いリクエストが後から返された場合、新しいデータを上書きする可能性があり、間違ったデータが表示されます。

  • dependencyの欠落: hookのuseEffectが外部変数を使用しているのにdependency arrayで宣言していない場合、hookは古い値(stale closure)を使用します。

  • 毎回のレンダリング時に不要な新しいオブジェクトを返す: hookが毎回{ data, loading, error }をオブジェクトリテラルで返す場合、下流のReact.memoを使用するコンポーネントは、参照が変わるため余分に再レンダリングされる可能性があります。実際に問題が測定できれば、useMemoを使用できます。

カスタムフックを書くべきではない場合:

  • ロジックが1つの場所だけで使用され、再利用の可能性がない場合。分離するとコードが追跡しにくくなるだけです(別のファイルにジャンプして理解する必要があります)。

  • ロジックが完全に純粋な計算(純粋関数)で、stateやeffectと無関係な場合。通常のJS関数を書くべきで、hookは必要ありません。

  • 複数のコンポーネント間でツリーの異なる場所のstate(ロジックだけでなく)を共有する必要がある場合。カスタムフックはこれができません。ContextまたはState管理ライブラリ(Zustand、Reduxなど)が必要です。

注意: カスタムフックはロジックの重複の問題を解決します。コンポーネント間のstate共有の問題を解決しません。この2つの問題を混同しないでください。

10. まとめ

カスタムフックは、stateを持つロジックをコンポーネントから分離するツールで、コードを簡潔にし、テストしやすく、再利用しやすくしてくれます。覚えるべきことは:useで名前を始める、Rules of Hooksに従う、renderではなくrenderHookでテストすることです。上記のuseFetch、useLocalStorage、useDebounceを使えば、useMediaQueryやuseFormなどの他のhookを「一度書いて、どこでも使う」という精神で書くフレームワークを手に入れることができます。

reactcustom-hookjavascriptfrontendtesting