1. ホーム
  2. React

【React】カスタムフックの作り方|ロジックを再利用する方法を解説

Share

React でコンポーネントを書いていると、複数の場所で同じロジックを繰り返してしまうことがあります。ウィンドウ幅を監視したり、localStorage と状態を同期させたり、データを取得したり——こうした処理を毎回コンポーネントに直接書いていると、コンポーネントが肥大化して読みにくくなります。そこで役立つのがカスタムフック(自作フック)です。この記事では、カスタムフックとは何か、どう作るのか、そして before/after でどれだけコンポーネントがすっきりするのかを、初心者〜中級者向けに丁寧に解説します。

カスタムフックとは何か

カスタムフックとは、useStateuseEffect といった React の組み込みフックを内部で呼び出し、ロジックを一つの関数にまとめたものです。名前が use で始まる関数、というのが最大の特徴です。特別な構文や API があるわけではなく、「フックを呼ぶただの JavaScript 関数」にすぎません。関数なので、状態や副作用を含むロジックを切り出して、複数のコンポーネントで再利用できます。

まずは、もっともシンプルな例として、真偽値(true / false)を切り替える useToggle を見てみましょう。モーダルの開閉やアコーディオンの表示切り替えなど、「オン・オフを反転させる」処理はアプリのあちこちで登場します。

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

// 真偽値のトグルをまとめたカスタムフック
export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);

  // 値を反転させる関数(毎回同じ関数を使い回す)
  const toggle = useCallback(() => {
    setValue((prev) => !prev);
  }, []);

  // 状態と操作関数を配列で返す
  return [value, toggle];
}

ポイントは、内部で useState を呼んで状態を持ち、toggle という操作関数と一緒に return している点です。返し方は自由で、ここでは useState にならって配列で返しています。使う側は次のように書けます。

Panel.jsx
import { useToggle } from './useToggle';

function Panel() {
  // useState を直接書かなくても、開閉ロジックが手に入る
  const [isOpen, toggleOpen] = useToggle(false);

  return (
    <div>
      <button onClick={toggleOpen}>
        {isOpen ? '閉じる' : '開く'}
      </button>
      {isOpen && <p>詳細な内容です。</p>}
    </div>
  );
}

コンポーネント側には useState も反転処理も出てきません。「開閉できる何か」という意図だけが残り、コードがぐっと読みやすくなります。これがカスタムフックの基本的な効果です。

カスタムフックの命名とルール

カスタムフックを作るうえで守るべき決まりがあります。まず名前についてですが、フックは必ず use で始めます。これは単なる慣習ではなく、React(の Lint ルール)が「この関数はフックである」と認識し、フックのルール違反を検出するための重要な目印です。getWindowWidth のように use で始まらない名前にすると、内部でフックを呼んでもフックとして扱われず、チェックが働きません。

次に「フックのルール」です。これは組み込みフックにもカスタムフックにも共通して適用される、React の根本的な決まりです。主なルールを表にまとめます。

ルール説明
名前は use で始めるReact がフックだと認識し、ルール違反を検出できるようにするための規則です。
トップレベルでのみ呼ぶ条件分岐(if)やループ、ネストした関数の中でフックを呼んではいけません。呼ぶ順番が毎回同じになるようにするためです。
関数コンポーネントかカスタムフックの中でのみ呼ぶ通常の JavaScript 関数やクラスの中では呼べません。フックを呼べるのはこの2種類の場所だけです。

「トップレベルでのみ呼ぶ」というのは、たとえば if (open) { const [x] = useState(0); } のように条件つきでフックを呼ぶのは禁止、という意味です。React は各フックの呼び出しを「何番目に呼ばれたか」という順番で管理しているため、レンダリングのたびに呼ぶ数や順番が変わると状態がずれてしまいます。カスタムフックを作るときも、この順番を崩さないよう、フックは関数の一番外側で呼びましょう。

重複するロジックをカスタムフックに切り出す

カスタムフックの真価は、複数のコンポーネントで重複するロジックをまとめられる点にあります。例として、ブラウザのウィンドウ幅を監視する処理を考えます。まずはカスタムフックを使わずに書いた before のコードです。

before:コンポーネントに直接書いた場合

Header.jsx
import { useState, useEffect } from 'react';

function Header() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    // 幅が変わるたびに state を更新する
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    // 後片付け(イベントの解除)
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // 本来やりたい「表示の出し分け」より、監視の準備が目立つ
  return <header>{width < 768 ? 'モバイル表示' : 'PC表示'}</header>;
}

やりたいのは「幅によって表示を変える」ことなのに、その前段の「幅を監視する仕組み」がコンポーネントの大半を占めています。しかも、別のコンポーネントでも幅を知りたくなったら、この useEffect をまるごとコピーすることになります。これを useWindowWidth というカスタムフックに切り出しましょう。

after:カスタムフックに切り出した場合

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

// ウィンドウ幅を監視して現在の幅を返すカスタムフック
export function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width; // 現在の幅を返すだけ
}
Header.jsx
import { useWindowWidth } from './useWindowWidth';

function Header() {
  const width = useWindowWidth(); // 監視の詳細は隠れる

  return <header>{width < 768 ? 'モバイル表示' : 'PC表示'}</header>;
}

コンポーネントは useWindowWidth() を呼ぶだけになり、本来の関心である「表示の出し分け」だけが残りました。監視の仕組みは useWindowWidth.js に隠れ、必要なコンポーネントはこの1行で同じ機能を得られます。ロジックを再利用しても、状態はフックを呼び出したコンポーネントごとに独立して持たれる点も重要です。カスタムフックが共有するのはロジックであって、状態そのものは共有されません。

状態と副作用をまとめる:useLocalStorage

もう少し実用的な例として、状態を localStorage と同期させる useLocalStorage を作ってみます。localStorage はブラウザにデータを保存する仕組みで、ページを再読み込みしても値が残ります。これを useState のように使えると便利です。

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

// key で指定した値を localStorage と同期させるフック
export function useLocalStorage(key, initialValue) {
  // 初回だけ localStorage を読む(無ければ初期値)
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  // value が変わるたびに localStorage へ書き込む
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  // useState と同じ [値, 更新関数] の形で返す
  return [value, setValue];
}

返す形を [value, setValue] という useState と同じインターフェースにしているため、使う側は useState の感覚でそのまま扱えます。

ThemeToggle.jsx
import { useLocalStorage } from './useLocalStorage';

function ThemeToggle() {
  // 再読み込みしても選んだテーマが残る
  const [theme, setTheme] = useLocalStorage('theme', 'light');

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

このように、返す値の形を組み込みフックにそろえると、学習コストが下がり使いやすいフックになります。「どんな値を、どんな形で返すか」を意識して設計するのがコツです。

データ取得をまとめる:useFetch

データ取得は、多くのコンポーネントで「読み込み中」「エラー」「取得結果」という3つの状態を扱う、典型的な重複ロジックです。これを useFetch にまとめると、各コンポーネントは取得結果を受け取るだけで済みます。

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

// 指定した URL からデータを取得するフック
export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 取得が完了する前にアンマウントされた場合に備えるフラグ
    let ignore = false;
    setLoading(true);

    fetch(url)
      .then((res) => res.json())
      .then((json) => {
        if (!ignore) {
          setData(json);
          setError(null);
        }
      })
      .catch((err) => {
        if (!ignore) setError(err);
      })
      .finally(() => {
        if (!ignore) setLoading(false);
      });

    // 後片付け:古いリクエストの結果を無視する
    return () => {
      ignore = true;
    };
  }, [url]);

  return { data, loading, error };
}

ignore フラグは、url が切り替わったり画面から消えたりしたあとに、古いリクエストの結果で状態を更新してしまう問題を防ぐための工夫です。クリーンアップ関数で ignoretrue にし、完了処理の中でこのフラグを確認しています。使う側は3つの状態を受け取り、表示を分けるだけです。

UserList.jsx
import { useFetch } from './useFetch';

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

  if (loading) return <p>読み込み中...</p>;
  if (error) return <p>エラーが発生しました</p>;

  return (
    <ul>
      {data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

取得のたびに loadingerror の管理を書く必要がなくなり、コンポーネントは「取れたデータをどう表示するか」だけに集中できます。同じ useFetch を別のコンポーネントで使い回せば、データ取得のパターンをアプリ全体で統一できます。

カスタムフックを作るときの考え方

カスタムフックは「同じロジックが2箇所以上に出てきたら切り出す」くらいの気持ちで十分です。最初から完璧な共通化を目指す必要はありません。まずはコンポーネントに直接書き、重複や見通しの悪さを感じたタイミングで use〇〇 に抜き出す、という順番が自然です。切り出す際は、そのフックが「何を受け取り(引数)」「何を返すか(戻り値)」という入出力を明確に決めると、使いやすく再利用しやすいフックになります。

また、カスタムフックはあくまでロジックの再利用の手段であり、見た目(JSX)の再利用ではない点に注意してください。UI を共通化したいときはコンポーネントを、状態や副作用を含むロジックを共通化したいときはカスタムフックを使う、と役割を分けて考えると迷いません。

まとめ

カスタムフックは、use で始まる名前を持ち、内部で useStateuseEffect などの組み込みフックを呼んでロジックをまとめ、必要な値を return するだけの JavaScript 関数です。特別な仕組みはありませんが、useToggleuseWindowWidthuseLocalStorageuseFetch のように、複数のコンポーネントで重複しがちなロジックを切り出すことで、コンポーネントは本来の関心事だけに集中でき、コードが大きくすっきりします。作るときは、名前を use で始めること、フックはトップレベルで・関数コンポーネントかカスタムフックの中でのみ呼ぶことという「フックのルール」を守りましょう。同じロジックが繰り返し登場したら、それをカスタムフックにまとめるサインだと考えてみてください。

参考ページ