【React】カスタムフックを自作してロジックを再利用する

JavaScript

こんにちは、かつコーチです。

前回は「useCallbackで関数の再生成を防ぐ」を解説しました。

useStateuseEffectuseRef と一通り基本のフックを見てきたので、今回はそれらを組み合わせてカスタムフックを自作する方法をまとめます。

複数のコンポーネントで同じようなロジックを書いていて「これ、まとめられないかな」と感じたことがある人には特に役立つ内容です。

カスタムフックとは?

「use」で始まる自作の関数

カスタムフックとは、useStateuseEffect などの標準フックを組み合わせて作る、自作の関数のことです。

名前を use から始めるのが唯一のルールで、それ以外は普通の関数と同じです。

import { useEffect, useState } from "react";

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

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

  return width;
}

これを別のコンポーネントから呼び出すと、ウィンドウ幅を管理するロジックを丸ごと使い回せます。

function ResponsiveMessage() {
  const width = useWindowWidth();

  return <p>{width < 768 ? "スマホ表示です" : "PC表示です"}</p>;
}

useStateuseEffect によるロジックが useWindowWidth の中に閉じ込められているので、ResponsiveMessage 側は「幅の数値だけ」を意識すればよくなっています。

なぜカスタムフックが必要なのか

コンポーネントを実装していると、「フォームの入力管理」や「APIからのデータ取得」など、見た目は違っても中身のロジックがそっくりなコンポーネントが増えてきます。

そのたびに useStateuseEffect を丸ごとコピペしていると、バグ修正のときに同じ修正をあちこちに反映させる羽目になります。

カスタムフックに切り出しておけば、修正箇所は1か所で済み、コンポーネント側は「見た目」に集中できます。

カスタムフックの作り方:手順で理解する

手順1:重複しているロジックを見つける

まずは、複数のコンポーネントで似たような useState + useEffect の組み合わせがないか探します。

// UserProfile.tsx
function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

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

  if (loading) return <p>読み込み中...</p>;
  return <p>{user?.name}</p>;
}

このような「fetchしてloadingを管理する」ロジックは、記事一覧や商品詳細など、いろいろな画面で似たパターンが登場しがちです。

手順2:ロジック部分だけを関数に切り出す

見た目に関わる部分(JSX)は残し、状態管理と副作用の部分だけを関数に抜き出します。

import { useEffect, useState } from "react";

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

  useEffect(() => {
    let isCancelled = false;
    setLoading(true);

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

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

  return { data, loading, error };
}

isCancelled フラグで、コンポーネントがアンマウントされた後に setState が呼ばれてしまう事故を防いでいます。

手順3:コンポーネント側で呼び出す

type User = { id: number; name: string };

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

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

UserProfile からは useStateuseEffect の記述が消え、「取得したデータをどう表示するか」だけに集中できるようになりました。

同じ useFetch を商品詳細やお知らせ一覧など、他の画面でもそのまま再利用できます。

つまずきやすいポイント:フックのルールを破ってしまう

かつコーチが実際にハマった例

私がカスタムフックを作り始めた頃、条件分岐の中でフックを呼び出してしまい、「Rendered more hooks than during the previous render」という謎のエラーに遭遇したことがあります。

❌ Before:条件分岐の中でフックを呼び出している

function useUserData(userId: number | null) {
  if (userId === null) {
    return { data: null, loading: false };
  }

  // userIdがある場合だけuseStateが呼ばれる
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((json) => {
        setData(json);
        setLoading(false);
      });
  }, [userId]);

  return { data, loading };
}

userIdnull のときは早期リターンでフックの呼び出し自体をスキップしてしまうため、レンダリングごとにフックが呼ばれる回数が変わってしまいます。

Reactはフックの呼び出し順序を頼りに内部の状態を管理しているため、この順序が崩れると原因の分かりにくいエラーにつながります。

✅ After:フックは必ずトップレベルで、無条件に呼び出す

function useUserData(userId: number | null) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(userId !== null);

  useEffect(() => {
    if (userId === null) {
      setData(null);
      setLoading(false);
      return;
    }

    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((json) => {
        setData(json);
        setLoading(false);
      });
  }, [userId]);

  return { data, loading };
}

useStateuseEffect の呼び出し自体は毎回必ず実行し、userId による条件分岐はフックの内側に移すことでルール違反を解消しました。

「フックはコンポーネントやカスタムフックのトップレベルで、条件分岐やループの外に書く」というルールは、カスタムフックを作るときに特に意識する必要があります。

応用:複数のフックを組み合わせる

useFetchとuseRefを組み合わせて検索機能を作る

これまでに紹介したフックは、組み合わせることでさらに実用的なカスタムフックになります。

import { useCallback, useRef, useState } from "react";

function useDebouncedSearch(delay: number) {
  const [results, setResults] = useState<string[]>([]);
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  const search = useCallback(
    (keyword: string) => {
      if (timerRef.current) {
        clearTimeout(timerRef.current);
      }

      timerRef.current = setTimeout(async () => {
        const res = await fetch(`/api/search?q=${keyword}`);
        const json = await res.json();
        setResults(json.results);
      }, delay);
    },
[delay]

); return { results, search }; }

useRef でタイマーIDを保持し、useCallbacksearch 関数自体を安定させることで、これまでの記事で解説してきた知識が1つのカスタムフックに集約されています。

このように、標準フックの理解が深まるほど、カスタムフックとして表現できるロジックの幅も広がっていきます。

まとめ

この記事のポイント

  • カスタムフックはuseから始まる、標準フックを組み合わせた自作の関数
  • 重複したロジックをカスタムフックに切り出すと、コンポーネントは見た目に集中できる
  • フックは条件分岐やループの外側、トップレベルで無条件に呼び出す必要がある
  • useRefuseCallbackなど、これまで学んだフックはカスタムフックの中で組み合わせて使える

次に読むべき記事

次回は「useReducerで複雑な状態管理を整理する」です。

useState では管理しきれなくなった複雑な状態を、どう整理していくかを解説します。

→ 次の記事:useReducerで複雑な状態管理を整理する

タイトルとURLをコピーしました