【React】カスタムフックの設計パターン集

JavaScript

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

前回はAtomic Designで、コンポーネントの「見た目」の粒度を整理する考え方を解説しました。

今回は、その裏側にある「ロジック」の整理方法、つまりカスタムフックの設計パターンを扱います。

カスタムフックはuseから始まる関数を自作するだけ、と単純に理解している人も多いですが、実は設計次第で再利用性が大きく変わってきます。

今日は、実務でよく使う4つの設計パターンを、具体的なコードとともに紹介していきます。

カスタムフックの基本をおさらい

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

コンポーネントの中にuseStateuseEffectをそのまま書き続けると、コンポーネントが「見た目の責務」と「ロジックの責務」の両方を抱えることになります。

カスタムフックは、このロジック部分だけを切り出して、別の関数として再利用できるようにする仕組みです。

// カスタムフックにロジックを切り出す例
import { useState, useEffect } from "react";

const 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;
};

コンポーネント側は const width = useWindowWidth(); の1行を呼ぶだけで済み、見た目のコードに集中できます。

命名規則の徹底

カスタムフックは必ず use から始める、というのはReactの公式ルールとして厳密に決まっています。

これはただの慣習ではなく、ESLintの react-hooks プラグインが「useから始まる関数の中でのみHooksのルールをチェックする」という仕組みになっているためです。

命名を守らないと、Hooksのルール違反(条件分岐の中でHookを呼ぶなど)をLintが検知できなくなるので、必ず守るようにしましょう。

パターン1:状態集約フック

関連する複数のstateを1つにまとめる

フォームなど、複数の関連するstateを扱う場面では、それらをまとめて管理するフックが有効です。

❌ Before:コンポーネント内にstateが散らばる

const SignupForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [emailError, setEmailError] = useState("");
  const [passwordError, setPasswordError] = useState("");

  const validate = () => {
    if (!email.includes("@")) {
      setEmailError("メールアドレスの形式が正しくありません");
    }
    if (password.length < 8) {
      setPasswordError("パスワードは8文字以上にしてください");
    }
  };

  // ここからさらにJSXが続く…
  return <form>{/* 省略 */}</form>;
};

✅ After:フォームの状態と検証ロジックをフックに集約する

// hooks/useSignupForm.tsx
import { useState } from "react";

type FormState = {
  email: string;
  password: string;
};

type FormErrors = {
  email: string;
  password: string;
};

export const useSignupForm = () => {
  const [values, setValues] = useState<FormState>({ email: "", password: "" });
  const [errors, setErrors] = useState<FormErrors>({ email: "", password: "" });

  const validate = (): boolean => {
    const nextErrors: FormErrors = { email: "", password: "" };
    if (!values.email.includes("@")) {
      nextErrors.email = "メールアドレスの形式が正しくありません";
    }
    if (values.password.length < 8) {
      nextErrors.password = "パスワードは8文字以上にしてください";
    }
    setErrors(nextErrors);
    return !nextErrors.email && !nextErrors.password;
  };

  const setField = (key: keyof FormState, value: string) => {
    setValues((prev) => ({ ...prev, [key]: value }));
  };

  return { values, errors, setField, validate };
};

コンポーニント側は、フォームの中身を気にせず useSignupForm() を呼ぶだけになり、見た目とロジックがきれいに分離されます。

パターン2:非同期処理ラップフック

API通信の状態管理を共通化する

ローディング・エラー・データという3点セットの状態管理は、API通信のたびに何度も書くことになる典型的な繰り返し処理です。

// hooks/useFetch.tsx
import { useState, useEffect } from "react";

type FetchState<T> = {
  data: T | null;
  isLoading: boolean;
  error: string | null;
};

export const useFetch = <T,>(url: string): FetchState<T> => {
  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;

    const fetchData = async () => {
      setIsLoading(true);
      try {
        const res = await fetch(url);
        if (!res.ok) throw new Error(`HTTPエラー: ${res.status}`);
        const json: T = await res.json();
        if (!ignore) setData(json);
      } catch (e) {
        if (!ignore) setError(e instanceof Error ? e.message : "不明なエラー");
      } finally {
        if (!ignore) setIsLoading(false);
      }
    };

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

  return { data, isLoading, error };
};

ジェネリクス(<T,>)を使うことで、どんなレスポンス型のAPIでも同じフックを使い回せるのがポイントです。

コンポーネント側は const { data, isLoading, error } = useFetch<User[]>("/api/users"); のように、型を指定するだけで済みます。

パターン3:外部イベント購読フック

addEventListenerの後始末を隠蔽する

ウィンドウリサイズ・スクロール・キーボードイベントなど、外部イベントを購読する処理は、後始末(クリーンアップ)を忘れるとメモリリークの原因になります。

// hooks/useEventListener.tsx
import { useEffect, useRef } from "react";

export const useEventListener = <K extends keyof WindowEventMap>(
  eventName: K,
  handler: (event: WindowEventMap[K]) => void
) => {
  const savedHandler = useRef(handler);

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    const eventListener = (event: WindowEventMap[K]) => savedHandler.current(event);
    window.addEventListener(eventName, eventListener);
    return () => window.removeEventListener(eventName, eventListener);
  }, [eventName]);
};

useRef で最新のハンドラーを保持しておくことで、handler が毎回新しい関数として渡されても、イベントの登録・解除が無駄に繰り返されないよう工夫しています。

このあたりの「クロージャの罠」は、カスタムフックを設計するうえで一番はまりやすいポイントです。

つまずきやすいポイント:フックの中でフックを呼ぶ順序

条件分岐でHooksを呼んでしまう事故

私が実際に一度やってしまった失敗が、カスタムフックの中で条件によってHookの呼び出し回数が変わってしまうパターンです。

❌ Before:早期リターンの後にHookを呼んでしまう

const useUserProfile = (userId: string | null) => {
  if (!userId) {
    return { profile: null, isLoading: false };
  }

  // userIdがnullのときはここに到達しないため、
  // レンダリングのたびにHookの呼び出し回数が変わってしまう
  const [profile, setProfile] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 省略
  }, [userId]);

  return { profile, isLoading };
};

実行すると Rendered fewer hooks than expected という警告が出て、原因が分からずかなり時間を使いました。

Reactは「Hookが呼ばれた順番」でstateを紐づけているため、条件によって呼び出し回数が変わると内部状態が壊れてしまうのです。

✅ After:Hookは必ずトップレベルで呼び、条件分岐は中身で行う

const useUserProfile = (userId: string | null) => {
  const [profile, setProfile] = useState(null);
  const [isLoading, setIsLoading] = useState(!!userId);

  useEffect(() => {
    if (!userId) {
      setIsLoading(false);
      return;
    }
    // ここでuserIdを使った非同期処理を行う
  }, [userId]);

  return { profile, isLoading };
};

「Hookは早期リターンより前、トップレベルで呼ぶ」というルールは、eslint-plugin-react-hooksrules-of-hooks で機械的に検知できます。

自作のカスタムフックであっても必ずこのルールが適用されるので、Lintのエラーを無視せずに直す習慣をつけておきましょう。

応用:フックを合成して大きな機能を組み立てる

小さなフックを組み合わせる

設計に慣れてくると、小さなカスタムフックを複数組み合わせて、より大きな機能を作れるようになります。

// hooks/useUserSearch.tsx
import { useState } from "react";
import { useFetch } from "./useFetch";
import { useDebounce } from "./useDebounce";

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

export const useUserSearch = () => {
  const [keyword, setKeyword] = useState("");
  const debouncedKeyword = useDebounce(keyword, 300);
  const { data, isLoading } = useFetch<User[]>(
    `/api/users?q=${debouncedKeyword}`
  );

  return { keyword, setKeyword, users: data ?? [], isLoading };
};

useDebounce(入力の確定を待つ)と useFetch(API通信)という単機能のフックを組み合わせるだけで、「入力のたびにAPIを叩きすぎない検索機能」が完成します。

1つ1つのフックを小さく単機能に保っておくと、こうした組み合わせの自由度が大きく上がります。

まとめ

この記事のポイント

  • カスタムフックは、関連するstateをまとめる「状態集約フック」から設計を始めると分かりやすい
  • API通信のような繰り返し処理は、ジェネリクスを使った共通フックに切り出せる
  • 外部イベントの購読は、クリーンアップ処理の書き忘れに注意する
  • Hookは条件分岐の前、必ずトップレベルで呼び出す
  • 小さな単機能フックを組み合わせることで、大きな機能を組み立てられる

次に読むべき記事

設計パターンを学んだところで、次は実際に手を動かして、ReactとAPI連携でTODOアプリを作っていきます。

→ 次の記事:ReactとAPI連携で簡単なTODOアプリを作ってみる

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