目次
13 章

カスタムフック

ロジック再利用の標準的な道具。良いフックのインターフェイスの形、よく作るパターン、そしてフックに切り出すべきでない場合まで整理します。

12章の最後で useTheme という小さな関数を作って Context の使い勝手を上げました。実はこの useThemeカスタムフック(Custom Hook) のひとつの例でした。本章ではカスタムフックとは何か、なぜ作るか、どう作るかを本格的に扱います。

カスタムフックの型レベルのインターフェイス(ジェネリックフックなど)は18章(hooks の型付け)で TypeScript で固めます。本章でフックの基本原理をしっかりつかんでおけば、その後の章が軽く読めます。

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

ここまで私たちは コンポーネント(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 が重複しています。これを1か所にまとめて再利用できるでしょうか。カスタムフック が答えです。

カスタムフックとは #

カスタムフックは 名前が 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 のものかを識別するため、呼び出し順序は毎回同じである必要があります。

このルールの唯一の例外が React 19 の use() フックです。26章(Suspense と use())で扱います。

ルール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 で前のタイマーをキャンセルする部分が肝です。

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 で作られたカスタムフックなので、原理を理解しておくとライブラリの学習が早くなります。

本書の4部(モダン Next.js)ではもう一歩進んで、Server Components 環境では useFetch のようなクライアントフックなしに、サーバーコンポーネント関数の本体で直接 データフェッチを行うモデルを見ます。25章(データフェッチとキャッシュ)で扱います。

フックに切り出すべきでない場合 #

カスタムフックは強力な道具ですが、あらゆる場所で使うものではありません。2つ以上のコンポーネントが本当に同じロジックを繰り返すとき が、意味のある抽出のタイミングです。

1. 単純な useState のラッパー #

過剰な抽象化
function useName() {
  return useState('');
}

// 使い方
const [name, setName] = useName();

useState('') の1行を useName() の1行に置き換えても、抽象化のコストが増えるだけです。呼び出し側が何を受け取るのかを、もう1段階下まで覗かないとわからなくなります。1行で済むものはインラインのまま残すのがよいです。

2. 1か所でだけ使うロジック #

「いつか2つ目のコンポーネントでも使えるかもしれないから」という理由で、あらかじめフックに切り出すのも抽象化の負債です。本当に2つ目のコンポーネントが同じロジックを必要としたときに抽出すれば十分です。それまではコンポーネントの中にインラインで置くほうが明快です。

3. 画面の断片 #

画面を再利用したいなら、コンポーネントに切り出すべきで、フックに切り出すことではありません。フックの戻り値は値や関数であって、JSX ではありません。

良いフックのインターフェイスの形 #

よく作るフックを見ると、インターフェイスは2つのパターンのいずれかです。

  • タプル(配列)useState のように使いたいとき。const [value, setValue] = useToggle()
  • オブジェクト — 戻り値が3個以上だったり、意味を明確にしたいとき。const { data, loading, error } = useFetch(url)

戻り値が2個ならタプル、3個以上ならオブジェクトが一般的です。オブジェクトで返すと、呼び出し側で必要なものだけ選んで使え、新しいフィールドを追加しても既存の呼び出しコードが壊れません。

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

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

もうひとつ重要なのは、各コンポーネントがフックを呼び出すと、そのインスタンスが自分専用の state を持つ ということです。useCounter() を2つのコンポーネントが呼び出すと、カウントは2つ別々に作られます。つまりフックは コードは共有するが、state は共有しません。 2つが同じ state を見たいのであれば、11章(状態のリフトアップ)や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 同期のロジックは別の場所でもそのまま使えます。

練習問題 #

  1. useDebounce フックを自分で作って、検索ボックスに適用してみてください。query state は即座に更新される一方、debouncedQuery は 500ms 止まってから更新される様子をコンソールログで観察します。cleanup で前のタイマーをキャンセルする部分を外すとどうなるかも実際に試してみます。
  2. useFetch の弱点を発見。上の useFetch は、同じ URL を複数のコンポーネントが呼んでも、それぞれ別にリクエストを送ります(キャッシュなし)。5つのコンポーネントが /api/users/1 を同時に呼ぶ画面を作り、ネットワークタブで5回リクエストが走るのを確認してみてください。この問題を解決するために作られたのが TanStack Query です(本書では直接扱いません)。
  3. フックに切り出すべきでない場合の識別。次の3つのうち、どれをフックに切り出すのが適切か選んでみてください。(a) useState('') の1行、(b) localStorage と同期した state(useLocalStorage)、(c) ひとつのコンポーネントでだけ使う30行のフォームバリデーションロジック。正解は (b) だけです。なぜそうなるのかを1段落で答えてみてください。

一行まとめ: カスタムフック = 名前が use で始まり、他のフックを使う関数。フックのルールは、関数の最上位でだけ呼び出すこと、コンポーネントや他のフックの中からだけ呼び出すこと。よく作るパターンは useToggleuseLocalStorageuseDebounceuseFetch。フックは ロジックは共有するが state は共有しない。1行だったり1か所でだけ使うロジックはフックに切り出さず、インラインのまま残す。

次の章 #

ここまでは「どう動かすか」に集中してきました。次の 14章 パフォーマンス最適化では「どう速く動かすか」を扱う道具 — memouseMemouseCallback を見ていきます。そして React 19 の React Compiler がこれらの道具の役割をどう変えるかもあわせて整理します。

X