React基礎講座 #13 カスタムフック

読了 8分

前回は prop drilling を解決するツールである Context を学びながら、最後に useTheme という関数を作って使い勝手を高めました。実はこの useThemeカスタムフック (Custom Hook) の一例だったのです。今回はカスタムフックとは何か、なぜ作るのか、どのように作るのかを本格的に扱っていきます。

コンポーネント間でロジックを共有する問題 #

これまで私たちは コンポーネント (JSX を返す関数) 単位でコードを再利用してきました。ところで、再利用したいものが画面の断片ではなく ロジック だったらどうでしょうか?

たとえば次の2つのコンポーネントは、ほぼ同じロジックを繰り返しています。

src/UserProfile.jsx
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, [userId]);

  // ... 画面のレンダリング ...
}
src/PostList.jsx
function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => setPosts(data))
      .catch(err => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  // ... 画面のレンダリング ...
}

同じパターン — データ取得 + ローディング/エラーの state が重複しています。これをどうやって一箇所にまとめて再利用できるでしょうか? カスタムフックが答えです。

カスタムフックとは #

カスタムフックは 名前が use で始まる、他のフックを使う普通の関数 です。定義はそれだけです。新しい構文があるわけではなく、ただの慣習なのです。

シンプルなカスタムフック
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(initial);

  return { count, increment, decrement, reset };
}

この関数はコンポーネントではありません (JSX を返さないので)。ですが 関数の中で useState というフックを使っています。そのため、自分自身もフックになります。

使う側:

src/Counter.jsx
function Counter() {
  const { count, increment, decrement, reset } = useCounter(0);

  return (
    <div>
      <h2>{count}</h2>
      <button onClick={increment}>+1</button>
      <button onClick={decrement}>-1</button>
      <button onClick={reset}>リセット</button>
    </div>
  );
}

useCounter という1行で、カウンターのロジック全体がカプセル化されました。同じフックを別のコンポーネントで同じように呼び出すと、そのコンポーネントにも自分専用のカウンターができます。

注記
「名前が use で始まる」というのは単なる慣習ではありません。React は関数名が use で始まるかどうかで、その関数がフックなのかを判断し、フックのルール (下で説明) を適用するかどうかを決めます。ESLint の react-hooks プラグインも、同様にこのルールを強制します。必ず use で始まる名前を付けてください。

フックのルール #

カスタムフックを作るときも使うときも、すべてのフックには2つのルールがあります。

ルール1. フックは関数の最上位でのみ呼び出す #

間違った例 — 条件文の中
function App() {
  if (someCondition) {
    const [count, setCount] = useState(0);  // 🚫
  }
}

フックは コンポーネント関数の最上位レベル でのみ呼び出さなければなりません。条件文、ループ、ネストした関数の中で呼び出してはいけません。React がフックの呼び出し順序で、どの state がどの useState なのかを識別するため、呼び出し順序が毎回同じでなければならないからです。

ルール2. フックは React 関数の中でのみ呼び出す #

フックは コンポーネント関数 または 他のカスタムフックの中 でのみ呼び出すことができます。普通の JavaScript 関数の中で呼び出してはいけません。

間違った例 — 普通の関数の中で呼び出し
function fetchSomething() {
  const [data, setData] = useState(null);  // 🚫
}

これらのルールに違反すると ESLint が捕まえてくれますし、実行時には React がエラーを出します。

よく作って使うカスタムフックたち #

自分で作ることもあれば、ライブラリから持ってくることもある、よくあるカスタムフックの例をいくつか見ていきましょう。

useToggle — boolean のトグル #

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}
使い方
function App() {
  const [isOpen, toggleOpen] = useToggle();

  return (
    <>
      <button onClick={toggleOpen}>{isOpen ? '閉じる' : '開く'}</button>
      {isOpen && <div>パネルの内容</div>}
    </>
  );
}

チェックボックスのトグル、モーダルの開閉、メニューの展開と折りたたみなど、よく登場するパターンなので、一度作っておくと活用度が高いです。

useLocalStorage — state ↔ localStorage の同期 #

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

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

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

  return [value, setValue];
}
使い方
function Settings() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <select value={theme} onChange={(e) => setTheme(e.target.value)}>
      <option value="light">ライト</option>
      <option value="dark">ダーク</option>
    </select>
  );
}

useState と使い方はほぼ同じですが、値が自動的に localStorage に保存され、ページをリロードしても保持されます。このように 基本フックをそのまま使うようなインターフェース を維持すると、使う側にとって直感的です。

useDebounce — 値の変更を遅らせる #

タイピング中に1文字ごとに検索を送ると、サーバーに負担がかかります。ユーザーが少し止まるのを待ってから送りたいときにデバウンスを使います。

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

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

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

  return debounced;
}
使い方
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

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

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

query はキー入力のたびに即座に変わりますが、debouncedQuery は500msタイピングが止まった後にようやく更新されます。その結果、検索リクエストはユーザーが少し休んだときだけ1回ずつ起きます。

cleanup で前回のタイマーをキャンセルする部分が肝心です — value がよく変わると、毎回前回のタイマーをキャンセルして新しいタイマーをセットする方式なので、結果的に最後の変更後 delay の間だけ静かになると更新が起きます。

useFetch — データ取得 #

導入部で見た重複パターンをフックに抽出してみましょう。

src/hooks/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(`リクエスト失敗: ${res.status}`);
        return res.json();
      })
      .then(json => {
        if (!cancelled) setData(json);
      })
      .catch(err => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

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

  return { data, loading, error };
}
使い方
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラー: {error}</p>;
  return <p>{user.name}</p>;
}

function PostList() {
  const { data: posts, loading, error } = useFetch('/api/posts');

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

導入部の重複が消えて、各コンポーネントは画面を描くことだけに集中するようになりました。同じロジックを100カ所で使っているとしても、フック1つを直せば100カ所が一緒に変わります。

注記
実務では自分で useFetch を作るより、TanStack Query のようなライブラリを使うことが多いです。キャッシュ、再検証、バックグラウンド更新、ページネーションなど、私たちが直接実装するのが難しい部分をうまく整えて提供してくれるからです。ただしそれも結局 useEffect + useState で作られたカスタムフックなので、原理を理解しておけばライブラリの学習も速くなります。

カスタムフックの本当の価値 #

カスタムフックを作りながら最も印象的な点は 抽象化の自由さ です。私たちが抽出したのは単純な関数ではなく state を持つ動作の単位 です。カウンター、トグル、データ取得、デバウンスといった「機能」たちを、コンポーネントから分離して独立した単位として扱えるようになったのです。

もう一つ重要な点は 各コンポーネントがフックを呼び出すと、そのインスタンスは自分だけの state を持つ という事実です。useCounter() を2つのコンポーネントが呼び出すと、useState と同じように、カウントが2つ別々に作られます。つまり、フックはコードを共有するけれど state は共有しません。2つが同じ state を見なければならないなら、#11 で学んだ lifting state up や #12 の Context を使う必要があります。

自分でやってみる #

過去の記事で作ったコンポーネントたちをカスタムフックで整理してみましょう。

src/hooks/useToggle.js:

src/hooks/useToggle.js
import { useState, useCallback } from 'react';

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue(prev => !prev), []);
  return [value, toggle];
}

src/hooks/useLocalStorage.js:

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

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

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

  return [value, setValue];
}

src/App.jsx:

src/App.jsx
import { useToggle } from './hooks/useToggle';
import { useLocalStorage } from './hooks/useLocalStorage';

function App() {
  const [isMenuOpen, toggleMenu] = useToggle();
  const [name, setName] = useLocalStorage('userName', '');

  return (
    <div style={{ padding: '16px' }}>
      <h1>カスタムフックのデモ</h1>

      <section style={{ marginTop: '16px' }}>
        <button onClick={toggleMenu}>{isMenuOpen ? 'メニューを閉じる' : 'メニューを開く'}</button>
        {isMenuOpen && (
          <ul>
            <li>ホーム</li>
            <li>紹介</li>
            <li>連絡先</li>
          </ul>
        )}
      </section>

      <section style={{ marginTop: '16px' }}>
        <p>名前を入力してください (リロードしても保持されます):</p>
        <input value={name} onChange={(e) => setName(e.target.value)} />
        {name && <p>こんにちは{name}さん!</p>}
      </section>
    </div>
  );
}

export default App;

トグルメニューはクリックするたびに開閉し、名前の入力はページをリロードしてもそのまま保持されます。コンポーネントのコードはずっと短くなり、トグルや localStorage 同期のロジックは別のところでもそのまま持ってきて使えます。

まとめ #

今回の記事では、コンポーネント間でロジックを共有するツールであるカスタムフックを学びました。整理すると:

  • カスタムフック = 名前が use で始まり、他のフックを使う関数
  • フックのルール: 関数の最上位でのみ呼び出す、コンポーネントか他のフックの中でのみ呼び出す
  • よく作るパターン: useToggleuseLocalStorageuseDebounceuseFetch
  • フックは ロジックを共有 するが state は共有しない (state の共有は lifting/Context)
  • ライブラリ (TanStack Query など) も結局カスタムフックで作られたもの

これまで私たちは「どう動作させるか」に集中してきました。次の記事「React基礎講座 #14 パフォーマンス最適化」では「どう速く動かすか」を扱うツール — memouseMemouseCallback を見ていきます。よく誤用されるツールなので、いつ使うべきで、いつ使うべきでないか までを一緒に押さえていきましょう。

X