カスタムフック作り方完全ガイド〜命名規則・設計パターン・useSWR代替実装〜【2026年版】

Reactで同じuseStateuseEffectの組み合わせを何度も書いている自分に気づいたら、それは「カスタムフックを切り出すサイン」です。本記事ではカスタムフックの作り方を、命名規則・分割粒度・テスト戦略・StrictMode対応まで含めて体系的に整理します。useFetchuseDebounceuseLocalStorageuseToggleuseMediaQueryuseClickOutsideといった定番フックを自作する例を中心に、usehooks-tsreact-use@uidotdev/usehooksSWRTanStack Queryといった既存ライブラリとの使い分けも解説します。

カスタムフックは「ロジックの単位」を表現するための言語機能です。コンポーネントから状態管理・副作用・購読処理を切り離すことで、テスタブルで再利用しやすいコードベースに到達できます。一方でフックの粒度・命名・依存配列を誤ると、StrictModeで二重発火したり、ESLintのRules of Hooksに違反したりと、初心者がつまずきやすいポイントも多くあります。本ガイドはその両面を丁寧にカバーします。

  1. カスタムフックとは何か〜「ロジックの再利用」を可能にするReactの根幹概念〜
    1. HOC・Render Propsから「フック」への移行が解いた問題
    2. 「使う側」と「作る側」での視点の違い
    3. カスタムフックが「ステートを共有しない」という事実
  2. カスタムフックの命名規則〜「useで始める」がなぜ義務なのか〜
    1. 動詞・名詞・対象を組み合わせた命名パターン
    2. useで始めない関数との境界線〜純粋関数 vs カスタムフック〜
    3. boolean系・タプル返却系・オブジェクト返却系の命名
  3. 最初のカスタムフックを作る〜useToggleで型と返り値を学ぶ〜
    1. setState呼び出しを関数型にする理由
    2. タプル返却 vs オブジェクト返却の選択基準
    3. useCallbackで関数の参照同一性を保つ意味
  4. useFetch〜データフェッチを「自作する」ことで学ぶ落とし穴〜
    1. このコードに潜む「3つのバグ」
    2. AbortControllerとignoreフラグで競合状態を解消する
    3. なぜ実務ではSWRやTanStack Queryを使うのか
  5. useDebounce〜「入力遅延」を扱う2種類のアプローチ〜
    1. パターンA: 値をdebounceする
    2. パターンB: 関数をdebounceする(useDebouncedCallback)
    3. 2パターンの使い分けと既存ライブラリの選択
  6. useLocalStorage〜SSR・StrictMode・JSON対応を真面目に考える〜
    1. SSRとhydration mismatchを避ける工夫
    2. StrictModeでの安全性とイベントリスナーの登録
    3. 既存ライブラリ実装との比較ポイント
  7. useMediaQueryとuseClickOutside〜ブラウザAPI購読型カスタムフック〜
    1. useMediaQuery: matchMediaの購読
    2. useClickOutside: 要素外クリックの検出
    3. React 19のuseSyncExternalStoreとの関係
  8. カスタムフック設計の5原則〜分割粒度と単一責任〜
    1. フックの合成(composition)で大きなフックを作る
    2. useReducerと組み合わせて状態遷移を整理する
    3. 「isLoading + data + error」のフラグ地獄を避けるパターン
  9. Rules of HooksとStrictMode〜ESLint違反の典型例〜
    1. NGパターン: 条件分岐内でフックを呼ぶ
    2. NGパターン: 通常の関数からフックを呼ぶ
    3. StrictMode下の二重発火に備える3つのテクニック
  10. カスタムフックのテスト戦略〜renderHookとMSWで実用品質を保証する〜
    1. renderHookによる単体テスト
    2. MSW(Mock Service Worker)でuseFetchをテストする
    3. テストの粒度マトリクス
  11. 既存ライブラリ活用ガイド〜自作 vs 採用の判断基準〜
    1. 主要フックライブラリ一覧
    2. SWRをuseFetchの代わりに使う最小例
    3. TanStack Queryで「キャッシュ・無限スクロール・楽観的更新」を一気に獲得する
    4. 「自作」を選ぶ価値があるシーン
  12. カスタムフックを学ぶ学習リソースとスクール活用
  13. FAQ〜カスタムフックでよくある質問〜
    1. Q1. カスタムフックはどのタイミングで切り出せばいいですか?
    2. Q2. useStateやuseEffectが大量に並ぶフックはアンチパターンですか?
    3. Q3. カスタムフック内でreact-routerやnext/routerを使っても良いですか?
    4. Q4. StrictModeでuseEffectが2回走るのは本番でも起きますか?
    5. Q5. usehooks-tsとreact-useではどちらを選ぶべきですか?
    6. Q6. データフェッチを自作useFetchで通したいのですが、社内レビューで反対されました。なぜですか?
    7. Q7. カスタムフックはServer Componentsでも使えますか?
  14. まとめ〜「自分でフックを作る」が見える景色〜

カスタムフックとは何か〜「ロジックの再利用」を可能にするReactの根幹概念〜

カスタムフックとは、useから始まる名前を持ち、内部で他のフックを呼び出すJavaScript関数のことです。これだけ覚えれば仕様としては十分ですが、実務での価値は「ステートフルなロジックをコンポーネントから切り離して名前を付けられる」点にあります。コンポーネントは見た目を、フックはふるまいを担当する、という関心の分離が自然に実現されます。

HOC・Render Propsから「フック」への移行が解いた問題

React 16.8以前はロジック再利用にHigher-Order Component(HOC)やRender Propsが使われていましたが、ネストが深くなる「wrapper hell」が悩みの種でした。フックは関数呼び出し1行でロジックを取り込めるため、JSX構造を汚さずに合成できます。これはuseFetch()useDebounce()useLocalStorage()といった命名で「何をするか」が一目でわかるという可読性面の利点にもつながります。

「使う側」と「作る側」での視点の違い

useStateuseEffectといったBuilt-in Hookは「使うだけ」ですが、カスタムフックは「設計して作る」ものです。引数・返り値・副作用のタイミングをAPIとして設計する責任が発生します。これは小さな関数を書くというより、小さなライブラリを設計するに近い行為です。本記事ではこの設計視点を一貫して重視します。

カスタムフックが「ステートを共有しない」という事実

初心者がよく誤解するのが「同じカスタムフックを2か所で呼んだら状態が共有される?」という点です。答えはNoです。フックは呼び出されるたびに独立した状態を持ちます。状態を共有したい場合はuseContextや状態管理ライブラリ(Zustand・Jotai・Redux Toolkit)を組み合わせる必要があります。useStateとカスタムフックの関係についてはuseState完全ガイドもあわせて確認してください。

カスタムフックの命名規則〜「useで始める」がなぜ義務なのか〜

カスタムフックの名前は必ずuseで始める必要があります。これは慣習ではなく、ESLintのreact-hooks/rules-of-hooksがこの命名規則を頼りにフックかどうかを判定しているためです。useで始まらない関数の中でフックを呼び出してもESLintは検出できず、条件分岐の中でフックを呼んでしまう事故につながります。

動詞・名詞・対象を組み合わせた命名パターン

カスタムフックの命名には大きく3パターンあります。

命名パターン 具体例 適用シーン
use + 名詞(状態の保持) useUser / useCart / useTheme ドメインオブジェクトの取得・保持
use + 動詞(操作) useToggle / useDebounce / useFetch 何かを行うふるまい全般
use + 環境名(購読) useMediaQuery / useWindowSize / useOnlineStatus ブラウザAPI・外部イベントの購読

useで始めない関数との境界線〜純粋関数 vs カスタムフック〜

逆に、内部でフックを呼ばないユーティリティ関数をuseXxxと命名するのもアンチパターンです。useFormatのような名前は「フックではないただの整形関数」であるべき場合があり、これも紛らわしさを生みます。判定基準はシンプルで、内部でuseStateuseEffectuseContext・他のカスタムフックを呼ぶならuseで始め、そうでなければ通常の関数名にするです。

boolean系・タプル返却系・オブジェクト返却系の命名

返り値の型でも命名のニュアンスが変わります。booleanを返すフックはuseIsXxxuseHasXxx(useIsMobileuseHasFocus)、タプルを返すフックはuseState風(useToggle[on, toggle])、複合オブジェクトを返すフックはオブジェクト分割代入を意識した命名(useFetch{ data, error, isLoading })が読みやすくなります。usehooks-tsのソースを眺めると、これらの命名規則が徹底されているのがわかります。

最初のカスタムフックを作る〜useToggleで型と返り値を学ぶ〜

もっとも単純なカスタムフックはuseToggleです。boolean状態と反転関数のペアを返すだけですが、ここに「APIとしての設計」のエッセンスが詰まっています。

// hooks/useToggle.ts
import { useCallback, useState } from "react";

export function useToggle(initialValue: boolean = false): [boolean, () => void, (next: boolean) => void] {
  const [value, setValue] = useState(initialValue);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const setExplicit = useCallback((next: boolean) => setValue(next), []);
  return [value, toggle, setExplicit];
}

setState呼び出しを関数型にする理由

setValue((v) => !v)のように関数型updateを使っているのは、依存配列を空にしても最新の値を参照するためです。setValue(!value)と書くとクロージャに古いvalueが閉じ込められて、連続クリックで挙動がおかしくなります。これはuseStateの古典的な落とし穴で、useState完全ガイドでも詳しく扱っています。

タプル返却 vs オブジェクト返却の選択基準

useToggleはタプル[value, toggle]形式で返しています。タプル返却の利点は呼び出し側で名前を自由に付けられること(const [isOpen, toggleOpen] = useToggle()のように使い分けやすい)です。一方、要素が3つ以上になるとタプルは読みにくくなるので、{ data, error, isLoading }のようなオブジェクト返却に切り替えるのが定石です。react-useのソースを参考にすると、要素3つを境にタプル→オブジェクトに切り替わっているのが見えます。

useCallbackで関数の参照同一性を保つ意味

toggle関数をuseCallbackでラップしているのは、返り値の関数参照を毎レンダー変えないためです。これにより、useToggleの戻り値をuseEffectReact.memoの依存値に渡しても無限ループや不要な再レンダーを起こしません。useCallbackとuseMemoの使い分けはuseMemo vs useCallback完全比較を参照してください。

useFetch〜データフェッチを「自作する」ことで学ぶ落とし穴〜

カスタムフックの定番がuseFetchです。実務ではSWRTanStack Queryを使うべきですが、自作することで「なぜそれらが必要なのか」がよくわかります。素朴な実装は以下です。

// hooks/useFetch.ts (素朴版)
import { useEffect, useState } from "react";

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

  useEffect(() => {
    setIsLoading(true);
    fetch(url)
      .then((r) => r.json())
      .then((d) => setData(d))
      .catch((e) => setError(e))
      .finally(() => setIsLoading(false));
  }, [url]);

  return { data, error, isLoading };
}

このコードに潜む「3つのバグ」

上のコードには素朴に書きがちですが、本番品質では避けたい問題が3つあります。

  1. race condition: URLが連続で変わると、古いレスポンスが新しいレスポンスを上書きする可能性がある
  2. cleanup漏れ: コンポーネントがアンマウントされてもsetDataが走り、警告またはメモリリークを引き起こす
  3. StrictModeでの二重実行: 開発時にuseEffectが2回走り、fetchが2回飛ぶ

AbortControllerとignoreフラグで競合状態を解消する

// hooks/useFetch.ts (改良版)
import { useEffect, useState } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    let ignore = false;
    setIsLoading(true);

    fetch(url, { signal: controller.signal })
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((d) => { if (!ignore) setData(d as T); })
      .catch((e) => { if (!ignore && e.name !== "AbortError") setError(e); })
      .finally(() => { if (!ignore) setIsLoading(false); });

    return () => {
      ignore = true;
      controller.abort();
    };
  }, [url]);

  return { data, error, isLoading };
}

なぜ実務ではSWRやTanStack Queryを使うのか

このuseFetchはまだキャッシュ・リトライ・再検証・楽観的更新・サスペンス連携などを持っていません。本番アプリでこれらを自作するのは現実的でなく、SWRTanStack Queryを使うべきというのが結論です。それでも自作版を書く価値は、「フックの中でuseEffectをどう扱うか」を体感できる点にあります。useEffectのcleanup関数の理解はuseEffect完全ガイドで補強できます。

useDebounce〜「入力遅延」を扱う2種類のアプローチ〜

検索ボックスで毎キーストロークAPIを叩くのを避けるためのuseDebounceには、2つの設計があります。値を遅延させるパターンと関数を遅延させるパターンです。

パターンA: 値をdebounceする

// hooks/useDebounce.ts
import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay: number = 300): T {
  const [debounced, setDebounced] = useState(value);

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

  return debounced;
}

// 使い方
function SearchBox() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 400);
  const { data } = useFetch(`/api/search?q=${debouncedQuery}`);
  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

パターンB: 関数をdebounceする(useDebouncedCallback)

// hooks/useDebouncedCallback.ts
import { useCallback, useEffect, useRef } from "react";

export function useDebouncedCallback<T extends (...args: any[]) => void>(
  callback: T,
  delay: number = 300
) {
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  const callbackRef = useRef(callback);

  useEffect(() => { callbackRef.current = callback; }, [callback]);

  return useCallback((...args: Parameters<T>) => {
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => callbackRef.current(...args), delay);
  }, [delay]);
}

2パターンの使い分けと既存ライブラリの選択

値debounceは「検索ボックス→APIフェッチ」のような状態の伝播を遅らせたい場面で、関数debounceは「window resize時のlogging」のようなイベントハンドラ自体の発火を抑えたい場面で使い分けます。usehooks-tsには両方が、react-useにはuseDebounce(関数型)が、@uidotdev/usehooksにはuseDebounce(値型)が用意されています。

useLocalStorage〜SSR・StrictMode・JSON対応を真面目に考える〜

useLocalStorageはuseStateとlocalStorageを橋渡しするフックで、テーマ設定やフォームドラフトの保存に重宝します。ただし「SSRでwindowが無い」「タブ間で同期させたい」「JSONシリアライズ」の3点を真面目に扱うと、初心者向けサンプルとはかなり違うコードになります。

// hooks/useLocalStorage.ts
import { useCallback, useEffect, useState } from "react";

export function useLocalStorage<T>(key: string, initial: T) {
  const readValue = useCallback((): T => {
    if (typeof window === "undefined") return initial;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initial;
    } catch {
      return initial;
    }
  }, [key, initial]);

  const [value, setValue] = useState<T>(readValue);

  const setStored = useCallback((next: T | ((prev: T) => T)) => {
    setValue((prev) => {
      const v = next instanceof Function ? next(prev) : next;
      try { window.localStorage.setItem(key, JSON.stringify(v)); } catch {}
      return v;
    });
  }, [key]);

  // 他タブとの同期
  useEffect(() => {
    const handler = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        try { setValue(JSON.parse(e.newValue) as T); } catch {}
      }
    };
    window.addEventListener("storage", handler);
    return () => window.removeEventListener("storage", handler);
  }, [key]);

  return [value, setStored] as const;
}

SSRとhydration mismatchを避ける工夫

Next.jsなどのSSR環境でuseLocalStorageを使うときの罠はサーバーとクライアントで初期値が違ってhydration mismatchが起きることです。上のコードはtypeof window === "undefined"でガードしていますが、それでも初回レンダーはinitialになり、その直後にlocalStorageの値で再レンダーされます。テーマトグルなどでチラつきが嫌な場合は、suppressHydrationWarningか、useEffect内でsetValueする遅延初期化に切り替えるのが定石です。

StrictModeでの安全性とイベントリスナーの登録

StrictMode下ではuseEffectが2回走るため、addEventListenerが2回呼ばれます。cleanup関数でremoveEventListenerを確実に行うことで、リスナーが多重登録されるのを防ぎます。これはカスタムフック全般に共通する原則です。

既存ライブラリ実装との比較ポイント

usehooks-tsuseLocalStorageはカスタムイベントlocal-storageを発火させて同一タブ内でも他のフック呼び出し間で同期します。@uidotdev/usehooksはTypeScriptのジェネリクスをより厳密に縛っています。自作する場合の判断基準は「同一タブ内同期」「サーバー側初期値」「JSON以外のシリアライズ」のどこまでをサポートするかです。

useMediaQueryとuseClickOutside〜ブラウザAPI購読型カスタムフック〜

外部APIの変化を購読してReactのstateに反映するパターンも頻出です。useMediaQueryuseClickOutsideを見ていきましょう。

useMediaQuery: matchMediaの購読

// hooks/useMediaQuery.ts
import { useEffect, useState } from "react";

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    setMatches(mql.matches);
    return () => mql.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

// 使い方
const isMobile = useMediaQuery("(max-width: 768px)");

useClickOutside: 要素外クリックの検出

// hooks/useClickOutside.ts
import { RefObject, useEffect } from "react";

export function useClickOutside<T extends HTMLElement>(
  ref: RefObject<T>,
  handler: (event: MouseEvent | TouchEvent) => void
) {
  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      handler(event);
    };
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
}

// 使い方
function Dropdown() {
  const ref = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState(false);
  useClickOutside(ref, () => setOpen(false));
  return <div ref={ref}>...</div>;
}

React 19のuseSyncExternalStoreとの関係

React 18以降の外部ストア購読はuseSyncExternalStoreを使うのが正攻法です。concurrent rendering下で読み取り値の一貫性を保つために設計されており、自前のmatchMedia購読より安全です。react-useの最新版やusehooks-tsは内部でuseSyncExternalStoreを使う方向に移行しています。本格運用するなら自作版もこちらに合わせると良いでしょう。

カスタムフック設計の5原則〜分割粒度と単一責任〜

カスタムフックを実務で量産していくと、必ず「何個のフックに分割すべきか」という悩みに直面します。経験則的に効くガイドラインを5つ挙げます。

  • 原則1 単一責任: 1つのフックは1つのふるまいだけを担当する。useFetchAndDebounceのような合体名は危険信号
  • 原則2 引数で関心を分離: フックの内側でハードコードしたい衝動を抑え、URL・delay・initialなどは引数で受け取る
  • 原則3 戻り値の最小化: 「使うかもしれない値」まで返さない。後から増やす方が楽
  • 原則4 副作用の局所化: 1つのフックに複数のuseEffectがあるなら分割を検討する
  • 原則5 命名は呼び出し側の読みやすさ優先: useUser()は短いが、useCurrentUserWithPermissions()のほうが意図が伝わるなら採用する

フックの合成(composition)で大きなフックを作る

// hooks/useDebouncedSearch.ts
import { useDebounce } from "./useDebounce";
import { useFetch } from "./useFetch";

export function useDebouncedSearch<T>(query: string, delay = 400) {
  const debouncedQuery = useDebounce(query, delay);
  return useFetch<T[]>(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
}

このようにカスタムフック同士を組み合わせて新しいフックを作るのが、カスタムフックの真価です。原則1〜5を守って細かく作っておけば、後から自由に合成できます。

useReducerと組み合わせて状態遷移を整理する

// hooks/useAsync.ts
import { useReducer, useCallback } from "react";

type State<T> =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: null; error: Error };

type Action<T> =
  | { type: "start" }
  | { type: "success"; payload: T }
  | { type: "fail"; payload: Error };

function reducer<T>(_state: State<T>, action: Action<T>): State<T> {
  switch (action.type) {
    case "start": return { status: "loading", data: null, error: null };
    case "success": return { status: "success", data: action.payload, error: null };
    case "fail": return { status: "error", data: null, error: action.payload };
  }
}

export function useAsync<T>(asyncFn: () => Promise<T>) {
  const [state, dispatch] = useReducer(reducer<T>, { status: "idle", data: null, error: null });
  const run = useCallback(async () => {
    dispatch({ type: "start" });
    try {
      const data = await asyncFn();
      dispatch({ type: "success", payload: data });
    } catch (e) {
      dispatch({ type: "fail", payload: e as Error });
    }
  }, [asyncFn]);
  return { ...state, run };
}

「isLoading + data + error」のフラグ地獄を避けるパターン

useAsyncのState型に注目すると、statusフィールドに対する判別ユニオン(discriminated union)で「loading中にdataがある状態」が型レベルで存在不可能になっています。これは複数のboolean(isLoadingisError)を併走させるよりはるかに安全です。TypeScriptの判別ユニオン活用法はTypeScript完全実践ガイドもあわせて確認してください。

Rules of HooksとStrictMode〜ESLint違反の典型例〜

カスタムフックを書き始めて最初に詰まるのがRules of Hooks違反です。Reactには2つの絶対ルールがあります。

  1. フックはトップレベルでのみ呼び出す(if・for・early returnの中で呼ばない)
  2. フックはReactの関数(コンポーネントか他のカスタムフック)からのみ呼び出す

NGパターン: 条件分岐内でフックを呼ぶ

// NG: useState呼び出し回数がレンダー毎に変わる
function useUserOrGuest(isLoggedIn: boolean) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null); // ❌ Rules of Hooks違反
    return user;
  }
  return null;
}

// OK: 常に呼び、内部で分岐
function useUserOrGuest(isLoggedIn: boolean) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    if (isLoggedIn) fetchUser().then(setUser);
    else setUser(null);
  }, [isLoggedIn]);
  return user;
}

NGパターン: 通常の関数からフックを呼ぶ

// NG: useで始まらない関数からフックを呼んでいる
function getCurrentUser() {
  const [user] = useState(null); // ❌ ESLintが検出できない場合あり
  return user;
}

// OK: useで始める or イベントハンドラ内で完結させる
function useCurrentUser() {
  const [user] = useState(null);
  return user;
}

StrictMode下の二重発火に備える3つのテクニック

React 18のStrictModeは開発環境でuseEffectmount→unmount→mountと意図的に走らせます。これに耐えるカスタムフックを書くコツは次のとおりです。

  • 必ずuseEffectのcleanupを書く(AbortController・clearTimeout・removeEventListener)
  • 「初回だけ実行」をuseRef + booleanフラグで強引に実現しない(StrictModeで誤動作する)
  • 副作用の対象はべき等(idempotent)にする。同じ値で2回呼んでも安全な処理に設計する

カスタムフックのテスト戦略〜renderHookとMSWで実用品質を保証する〜

カスタムフックはコンポーネントから切り離せるからこそテストしやすい、というのが本来の利点です。Reactでは@testing-library/reactrenderHookを使うのが標準です。

renderHookによる単体テスト

// hooks/useToggle.test.ts
import { act, renderHook } from "@testing-library/react";
import { useToggle } from "./useToggle";

test("初期値は引数のbool", () => {
  const { result } = renderHook(() => useToggle(true));
  expect(result.current[0]).toBe(true);
});

test("toggleで反転する", () => {
  const { result } = renderHook(() => useToggle(false));
  act(() => { result.current[1](); });
  expect(result.current[0]).toBe(true);
});

MSW(Mock Service Worker)でuseFetchをテストする

// hooks/useFetch.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { useFetch } from "./useFetch";

const server = setupServer(
  http.get("/api/user", () => HttpResponse.json({ name: "tanaka" }))
);
beforeAll(() => server.listen());
afterAll(() => server.close());

test("成功時にdataがセットされる", async () => {
  const { result } = renderHook(() => useFetch<{ name: string }>("/api/user"));
  await waitFor(() => expect(result.current.isLoading).toBe(false));
  expect(result.current.data?.name).toBe("tanaka");
});

テストの粒度マトリクス

テスト対象 手法 ライブラリ
純粋な状態遷移(useToggle等) renderHook + act @testing-library/react + Vitest/Jest
非同期データ取得(useFetch等) renderHook + waitFor + APIモック MSW
ブラウザAPI購読(useMediaQuery等) jsdomでmatchMediaをモック @testing-library/jest-dom

既存ライブラリ活用ガイド〜自作 vs 採用の判断基準〜

カスタムフックは自作しても良いですが、世の中には十分に枯れたフックライブラリが多数あります。実務での判断は「業務固有か汎用か」「テストコストを払う価値があるか」で決まります。

主要フックライブラリ一覧

ライブラリ 特徴 こんなときに採用
usehooks-ts TypeScript前提、軽量、依存少 小さなSPAで定番フックだけ欲しい
react-use 100以上のフックを網羅、歴史長め マニアックな購読系を含めて広く欲しい
@uidotdev/usehooks モダンTypeScript・コピペ前提 依存追加を避けたい・コードを読みやすく所有したい
SWR stale-while-revalidate特化 Next.jsプロジェクトでシンプルなREST取得
TanStack Query キャッシュ・ミューテーション・楽観更新まで網羅 本格的なデータドリブンUI

SWRをuseFetchの代わりに使う最小例

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

function User({ id }: { id: string }) {
  const { data, error, isLoading } = useSWR(`/api/user/${id}`, fetcher);
  if (isLoading) return <p>読み込み中</p>;
  if (error) return <p>エラー</p>;
  return <p>{data.name}</p>;
}

TanStack Queryで「キャッシュ・無限スクロール・楽観的更新」を一気に獲得する

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn: () => fetch("/api/todos").then((r) => r.json()),
    staleTime: 60_000,
  });
}

function useAddTodo() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (title: string) => fetch("/api/todos", { method: "POST", body: JSON.stringify({ title }) }),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] }),
  });
}

「自作」を選ぶ価値があるシーン

とはいえ、自社のドメインに固有なフック(useCheckoutFlowuseFeatureFlagなど)は自作しか選択肢がありません。usehooks-tsなどをベースに、自社固有のhooks/ディレクトリを育てるのが現実的です。

カスタムフックを学ぶ学習リソースとスクール活用

カスタムフックは「書けば書くほど設計が見える」領域なので、まず手を動かすことが最優先です。とはいえ、独学で詰まったときの選択肢としてプログラミングスクールを併用する方も増えています。React+TypeScript+モダンフックの体系学習に向くサービスを並べておきます。

  • テックアカデミー: 短期集中のReactコースがあり、メンター付きでカスタムフックの設計レビューを受けられる
  • 侍エンジニア: マンツーマンで自分のポートフォリオに沿った設計指導が受けられる。hooks/ディレクトリ整備までフィードバックを得られる
  • DMM WEBCAMP: フロントエンドコースでReact/TypeScript/テスト戦略まで一気通貫で学べる
  • レバテック: スクールというより転職エージェントだが、React実務案件の傾向(SWR/TanStack Query/usehooks-tsの採用率など)を知るのに最適

FAQ〜カスタムフックでよくある質問〜

Q1. カスタムフックはどのタイミングで切り出せばいいですか?

「同じuseState+useEffectの組み合わせを2回以上書きそうになったとき」が目安です。最初は1コンポーネント内のロジックでも、テストしたいと思った時点で切り出すのもおすすめです。テスタビリティの面でも、フックに切り出した瞬間にrenderHookで単体テストできるようになります。

Q2. useStateやuseEffectが大量に並ぶフックはアンチパターンですか?

必ずしもアンチパターンではありませんが、useEffectが3つ以上ある場合は分割を検討すべきサインです。useReducer+1つのuseEffectに置き換えられないか、useAsyncのような既存パターンに統合できないかを最初に考えます。

Q3. カスタムフック内でreact-routerやnext/routerを使っても良いですか?

良いですが、ルーティング依存のフックはユニットテストが難しくなります。useRouterを直接呼ぶ薄いフックと、純粋ロジックフックを分離しておくと、テスト時にルーターをモックする範囲を狭くできます。

Q4. StrictModeでuseEffectが2回走るのは本番でも起きますか?

本番ビルドでは1回しか走りません。ただし開発環境で「2回走っても正しく動く」設計にしておくことで、将来のconcurrent renderingでのre-mount時にも壊れない強いフックになります。cleanupは常に書くと覚えておけば十分です。

Q5. usehooks-tsとreact-useではどちらを選ぶべきですか?

TypeScript中心・小さなバンドルが欲しいならusehooks-ts、フック数の網羅性と古くからの実績を重視するならreact-useです。新規プロジェクトでは個人的にはusehooks-ts@uidotdev/usehooksのコピペ採用をおすすめします。

Q6. データフェッチを自作useFetchで通したいのですが、社内レビューで反対されました。なぜですか?

本記事のuseFetchセクションで挙げた通り、race condition・キャッシュ・リトライ・楽観更新といった機能を本気で実装すると、結局SWRやTanStack Queryと同じものを再発明することになります。学習目的の自作は素晴らしいですが、本番ではSWR/TanStack Queryを推奨するレビュアーが多数派です。

Q7. カスタムフックはServer Componentsでも使えますか?

useStateやuseEffectを内部で呼ぶ以上、Server Componentsでは使えません("use client"側のみ)。サーバー側ではただの非同期関数を書き、クライアント側ではカスタムフックで購読する、という分業が現代Reactの基本構造です。

まとめ〜「自分でフックを作る」が見える景色〜

カスタムフックは「Reactでロジックを抽象化するための言語機能」であり、命名規則・分割粒度・テスト戦略・StrictMode対応まで含めて設計する対象です。useToggleのような小さなフックから始め、useFetchでcleanupとrace conditionを学び、useLocalStorageでSSRとhydration mismatchに対処し、useMediaQueryuseClickOutsideでブラウザAPI購読を経験する——この順で書いていけば、実務に必要なカスタムフックの設計感覚は十分身につきます。

そして次のステップはusehooks-tsreact-use@uidotdev/usehooksのソースコードを読むことです。「同じ目的のフックを他の人がどう設計したか」を読むことで、自分のフックの粒度・命名・副作用の扱いが洗練されていきます。データフェッチはSWRTanStack Queryに任せ、ドメイン固有ロジックは自作する——この切り分けが定まれば、カスタムフックは怖くなくなります。本記事の他にもuseState完全ガイドuseEffect完全ガイドuseMemo vs useCallback比較を起点に、フックの理解を立体的に深めていってください。

コメント

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