カスタムフックとは、名前が「use」で始まり、中でほかのフックを呼ぶ関数のことです。 useStateやuseEffectを組み合わせた処理に名前を付けて切り出すことで、複数のコンポーネントから同じロジックを使い回せるようになります。特別なAPIは不要で、ふつうの関数として書くだけです。

この記事では、よく使う10本のカスタムフックをジェネリクス付きのTypeScriptで掲載し、それぞれに renderHook を使ったテストを添えました。すべて React 19.3.0・TypeScript 6.0.3・Vitest 5.0.3 で実際に動かし、14件のテストが通ることを確認済みです。あわせて、命名ルールを外したときや条件分岐の中で呼んだときに出るESLintと実行時のエラー文、サーバー側の描画で起きる window is not defined、通信の競合を防ぐAbortControllerの使い方も、実際の出力つきで紹介します。

カスタムフック10選の早見表

フック 用途 引数 戻り値 中で使うフック
useToggle 開閉・ON/OFFの切り替え 初期値 { value, toggle, setOn, setOff } useState / useCallback
usePrevious 1つ前の値と比べる 値 前回の値(最初は undefined) useState
useDebounce 入力が止まってから検索する 値、待ち時間 遅れて反映される値 useState / useEffect
useLocalStorage 設定をブラウザに保存する キー、初期値 [値, 更新関数, 削除関数] useState / useCallback
useMediaQuery 画面幅などで表示を切り替える メディアクエリ 一致しているか(真偽値) useSyncExternalStore
useClickOutside メニューの外側を押したら閉じる ref、ハンドラ なし useRef / useEffect
useFetch URLからJSONを取得する URL { data, error, loading } useState / useEffect
useInterval 一定間隔で処理する コールバック、間隔 なし useRef / useEffect
useEventListener キー入力などwindowのイベントを受け取る イベント名、ハンドラ なし useRef / useEffect
useWindowSize ウィンドウの幅・高さを得る なし { width, height } useState / useEffect

守るルールは次の3つです。

ルール 内容 外したときに起きること
名前を use で始める useToggle のように、use +大文字で始める ESLintがフックとして検査できなくなる
トップレベルで呼ぶ 条件分岐・ループ・早期returnのあとで呼ばない 実行時に「Rendered more hooks…」などのエラーで止まる
コンポーネントかフックの中で呼ぶ 通常の関数やイベントハンドラの中では呼ばない ESLintのエラー、実行時のエラー

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React カスタムフックの作り方|useFetch等コピペ用10選」

カスタムフックの作り方(3ステップ)

コンポーネントからロジックを切り出す

開閉状態を持つコンポーネントを例にします。切り出す前はこうです。

import { useState } from "react";

export function HelpPanel() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen((v) => !v)}>ヘルプ</button>
      {open && <p>使い方の説明です。</p>}
    </div>
  );
}

手順は3つです。

  1. stateとそれを操作する処理をひとまとめにして、関数に移す。
  2. 関数名を use で始める。
  3. コンポーネントが必要とする値と関数だけを返す。
import { useCallback, useState } from "react";

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return { value, toggle } as const;
}

export function HelpPanel() {
  const { value: open, toggle } = useToggle();
  return (
    <div>
      <button onClick={toggle}>ヘルプ</button>
      {open && <p>使い方の説明です。</p>}
    </div>
  );
}

カスタムフックが共有するのはロジックで、stateそのものではありません。 2つのコンポーネントが同じ useToggle を呼んでも、開閉状態はそれぞれ別々に持ちます。

戻り値の形の選び方

形 例 向いている場面
タプル(配列) const [value, setValue] = useLocalStorage(...) 値が2〜3個。呼び出し側で自由に名前を付けたい
オブジェクト const { data, error, loading } = useFetch(...) 値が多い。必要なものだけ取り出したい
単一の値 const debounced = useDebounce(text) 返すものが1つ
なし useClickOutside(ref, close) 副作用を登録するだけ

タプルで返すときは as const を付けます。付けないと (boolean | (() => void))[] のような配列型に広がり、呼び出し側で型が合わなくなります。

検証環境とテストの準備

項目 バージョン
React / react-dom 19.3.0
TypeScript 6.0.3(strict: true、tsc --noEmit でエラー0件)
Vitest 5.0.3(environment: "jsdom"、jsdom 30.1.1)
@testing-library/react 16.3.3
eslint / eslint-plugin-react-hooks 10.12.0 / 7.1.1
Node.js 22.22.2
npm i react react-dom
npm i -D typescript @types/react @types/react-dom vitest jsdom @testing-library/react @testing-library/dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "jsdom", include: ["*.test.tsx"] } });

フックは hooks.ts、テストは hooks.test.tsx にまとめます。それぞれの先頭は次のとおりです。

// hooks.ts の先頭
import {
  useCallback, useEffect, useRef, useState, useSyncExternalStore,
  type RefObject,
} from "react";
// hooks.test.tsx の先頭
import { act, renderHook, waitFor } from "@testing-library/react";
import { useEffect, useRef, useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
  useClickOutside, useDebounce, useEventListener, useFetch, useInterval,
  useLocalStorage, useMediaQuery, usePrevious, useToggle, useWindowSize,
} from "./hooks";

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
  localStorage.clear();
});

renderHook は、フックを呼ぶだけのテスト用コンポーネントを内部で作ってくれる関数です。result.current に最新の戻り値が入り、stateを変える操作は act で包みます。

コピペで使えるカスタムフック10選(テスト付き)

useToggle:真偽値を切り替える

export function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const setOn = useCallback(() => setValue(true), []);
  const setOff = useCallback(() => setValue(false), []);
  return { value, toggle, setOn, setOff } as const;
}
describe("useToggle", () => {
  it("toggle で反転し、setOn / setOff で固定できる", () => {
    const { result } = renderHook(() => useToggle());
    expect(result.current.value).toBe(false);
    act(() => result.current.toggle());
    expect(result.current.value).toBe(true);
    act(() => result.current.setOff());
    expect(result.current.value).toBe(false);
  });
});

usePrevious:1つ前の値を取り出す

「前回より増えたか減ったか」を表示したいときに使います。描画中にrefを読み書きしない形にしてあるため、React Compilerを導入した構成でもそのまま使えます(コンパイラを通した動作は今回は未実行です)。

export function usePrevious<T>(value: T): T | undefined {
  const [current, setCurrent] = useState<T>(value);
  const [previous, setPrevious] = useState<T | undefined>(undefined);
  if (!Object.is(current, value)) {
    setPrevious(current);
    setCurrent(value);
  }
  return previous;
}
describe("usePrevious", () => {
  it("1つ前の値を返す", () => {
    const { result, rerender } = renderHook(({ v }) => usePrevious(v), { initialProps: { v: 1 } });
    expect(result.current).toBeUndefined();
    rerender({ v: 2 });
    expect(result.current).toBe(1);
    rerender({ v: 5 });
    expect(result.current).toBe(2);
  });
});

useDebounce:入力が止まってから反映する

検索欄の入力のたびにAPIを呼ぶ代わりに、入力が止まって一定時間たってから呼ぶときに使います。

export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState<T>(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

時間に関わるフックは、偽のタイマー(vi.useFakeTimers)で時間を進めてテストします。

describe("useDebounce", () => {
  it("delay が過ぎるまで前の値のまま", () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(({ v }) => useDebounce(v, 300), { initialProps: { v: "a" } });
    rerender({ v: "ab" });
    rerender({ v: "abc" });
    act(() => { vi.advanceTimersByTime(299); });
    expect(result.current).toBe("a");
    act(() => { vi.advanceTimersByTime(1); });
    expect(result.current).toBe("abc");
  });
});

useLocalStorage:ブラウザに保存するstate

型引数 T で保存する値の型を決めます。サーバー側では window がないため初期値を返し、保存済みのJSONが壊れていた場合も初期値に戻します。

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [stored, setStored] = useState<T>(() => {
    if (typeof window === "undefined") return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw === null ? initialValue : (JSON.parse(raw) as T);
    } catch {
      return initialValue;
    }
  });
  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStored((prev) => {
        const next = value instanceof Function ? value(prev) : value;
        try {
          window.localStorage.setItem(key, JSON.stringify(next));
        } catch {
          // 容量超過や無効化時は state だけ更新する
        }
        return next;
      });
    },
    [key],
  );
  const remove = useCallback(() => {
    window.localStorage.removeItem(key);
    setStored(initialValue);
  }, [key, initialValue]);
  return [stored, setValue, remove] as const;
}
describe("useLocalStorage", () => {
  it("保存済みの値を読み、更新を書き込む", () => {
    localStorage.setItem("theme", JSON.stringify("dark"));
    const { result } = renderHook(() => useLocalStorage<"light" | "dark">("theme", "light"));
    expect(result.current[0]).toBe("dark");
    act(() => result.current[1]("light"));
    expect(localStorage.getItem("theme")).toBe('"light"');
  });
  it("壊れた JSON は初期値に戻す", () => {
    localStorage.setItem("cart", "{broken");
    const { result } = renderHook(() => useLocalStorage<string[]>("cart", []));
    expect(result.current[0]).toEqual([]);
  });
});

JSON.parse(raw) as T は型を信じているだけなので、外部から書き換えられる可能性がある値は、読み込んだあとに形を検査すると安心です。

useMediaQuery:メディアクエリの一致を得る

ブラウザ側の状態を購読するときは useSyncExternalStore が向いています。3番目の引数がサーバー側で使われる値です。

export function useMediaQuery(query: string, serverFallback = false): boolean {
  const subscribe = useCallback(
    (onChange: () => void) => {
      const mql = window.matchMedia(query);
      mql.addEventListener("change", onChange);
      return () => mql.removeEventListener("change", onChange);
    },
    [query],
  );
  return useSyncExternalStore(
    subscribe,
    () => window.matchMedia(query).matches,
    () => serverFallback,
  );
}

jsdomには matchMedia がないため、テストでは差し替えます。

describe("useMediaQuery", () => {
  it("matchMedia の結果と変更に追従する", () => {
    let matches = false;
    const listeners = new Set<() => void>();
    vi.stubGlobal("matchMedia", (query: string) => ({
      get matches() { return matches; },
      media: query,
      addEventListener: (_: string, cb: () => void) => listeners.add(cb),
      removeEventListener: (_: string, cb: () => void) => listeners.delete(cb),
    }));
    const { result } = renderHook(() => useMediaQuery("(min-width: 768px)"));
    expect(result.current).toBe(false);
    act(() => { matches = true; listeners.forEach((cb) => cb()); });
    expect(result.current).toBe(true);
  });
});

useClickOutside:要素の外側のクリックを検知する

ドロップダウンやダイアログを「外側を押したら閉じる」ときに使います。ハンドラはrefに入れておくので、呼び出し側が毎回新しい関数を渡しても、リスナーの付け直しは起きません。

export function useClickOutside<T extends HTMLElement>(
  ref: RefObject<T | null>,
  handler: (event: MouseEvent | TouchEvent) => void,
) {
  const saved = useRef(handler);
  useEffect(() => {
    saved.current = handler;
  }, [handler]);
  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      saved.current(event);
    };
    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);
    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref]);
}
describe("useClickOutside", () => {
  it("外側の mousedown でだけ handler を呼ぶ", () => {
    const inside = document.createElement("div");
    const outside = document.createElement("button");
    document.body.append(inside, outside);
    const handler = vi.fn();
    renderHook(() => {
      const ref = useRef<HTMLDivElement>(inside);
      useClickOutside(ref, handler);
    });
    inside.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
    expect(handler).toHaveBeenCalledTimes(0);
    outside.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
    expect(handler).toHaveBeenCalledTimes(1);
  });
});

useFetch:URLからJSONを取得する

型引数 T が取得するデータの型です。URLが変わるかコンポーネントが画面から外れたら、AbortControllerで前の通信を中止します。

export type FetchState<T> = { data: T | null; error: Error | null; loading: boolean };

export function useFetch<T>(url: string | null): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null, error: null, loading: url !== null,
  });
  useEffect(() => {
    if (url === null) return;
    const controller = new AbortController();
    setState((s) => ({ ...s, loading: true, error: null }));
    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json() as Promise<T>;
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((e: unknown) => {
        if (controller.signal.aborted) return; // 中止した通信の結果は捨てる
        setState({ data: null, error: e instanceof Error ? e : new Error(String(e)), loading: false });
      });
    return () => controller.abort();
  }, [url]);
  return state;
}

使う側の例です。

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

export function ItemList({ keyword }: { keyword: string }) {
  const debounced = useDebounce(keyword, 300);
  const { data, error, loading } = useFetch<Item[]>(
    debounced ? `/api/items?q=${encodeURIComponent(debounced)}` : null,
  );
  if (loading) return <p>読み込み中…</p>;
  if (error) return <p>取得できませんでした: {error.message}</p>;
  return <ul>{data?.map((item) => <li key={item.id}>{item.name}</li>)}</ul>;
}

テストでは fetch を、URLごとに応答の遅さを変えられる関数に差し替えます。

const delayedFetch = (delays: Record<string, number>) =>
  vi.fn((url: string, init?: RequestInit) =>
    new Promise<Response>((resolve, reject) => {
      const id = setTimeout(
        () => resolve(new Response(JSON.stringify({ url }), { status: 200 })),
        delays[url] ?? 0,
      );
      init?.signal?.addEventListener("abort", () => {
        clearTimeout(id);
        reject(new DOMException("The operation was aborted.", "AbortError"));
      });
    }));

describe("useFetch", () => {
  it("取得した JSON を data に入れる", async () => {
    vi.stubGlobal("fetch", delayedFetch({ "/api/items": 5 }));
    const { result } = renderHook(() => useFetch<{ url: string }>("/api/items"));
    expect(result.current.loading).toBe(true);
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual({ url: "/api/items" });
  });
  it("HTTP エラーは error に入れる", async () => {
    vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 404 })));
    const { result } = renderHook(() => useFetch("/api/none"));
    await waitFor(() => expect(result.current.error?.message).toBe("HTTP 404"));
  });
  it("URL を切り替えたら、遅れて届いた前の応答を使わない", async () => {
    vi.stubGlobal("fetch", delayedFetch({ "/api/slow": 80, "/api/fast": 10 }));
    const { result, rerender } = renderHook(({ url }) => useFetch<{ url: string }>(url), {
      initialProps: { url: "/api/slow" },
    });
    rerender({ url: "/api/fast" });
    await waitFor(() => expect(result.current.data).toEqual({ url: "/api/fast" }));
    await new Promise((r) => setTimeout(r, 120));
    expect(result.current.data).toEqual({ url: "/api/fast" });
  });
});

キャッシュ・再取得・ページ送りまで必要になったら、データ取得用のライブラリに任せると手間が減ります。useFetch は「1画面で1回取るだけ」の用途に向いています。

useInterval:一定間隔で処理する

delay に null を渡すと止まります。

export function useInterval(callback: () => void, delay: number | null) {
  const saved = useRef(callback);
  useEffect(() => {
    saved.current = callback;
  }, [callback]);
  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => saved.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}
describe("useInterval", () => {
  it("delay ごとに呼び、null で止まる", () => {
    vi.useFakeTimers();
    const cb = vi.fn();
    const { rerender } = renderHook(({ delay }) => useInterval(cb, delay), {
      initialProps: { delay: 1000 as number | null },
    });
    act(() => { vi.advanceTimersByTime(3000); });
    expect(cb).toHaveBeenCalledTimes(3);
    rerender({ delay: null });
    act(() => { vi.advanceTimersByTime(3000); });
    expect(cb).toHaveBeenCalledTimes(3);
  });
});

useEventListener:windowのイベントを型付きで受け取る

K extends keyof WindowEventMap とすることで、イベント名に応じてハンドラの引数の型が決まります("keydown" なら KeyboardEvent)。

export function useEventListener<K extends keyof WindowEventMap>(
  type: K,
  handler: (event: WindowEventMap[K]) => void,
) {
  const saved = useRef(handler);
  useEffect(() => {
    saved.current = handler;
  }, [handler]);
  useEffect(() => {
    const listener = (event: WindowEventMap[K]) => saved.current(event);
    window.addEventListener(type, listener);
    return () => window.removeEventListener(type, listener);
  }, [type]);
}
describe("useEventListener", () => {
  it("登録し、アンマウントで解除する", () => {
    const handler = vi.fn();
    const { unmount } = renderHook(() => useEventListener("keydown", handler));
    window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
    expect(handler).toHaveBeenCalledTimes(1);
    unmount();
    window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
    expect(handler).toHaveBeenCalledTimes(1);
  });
});

useWindowSize:ウィンドウの幅と高さ

初期値を 0 にしておき、Effectの中で実際の値を読みます。Effectはブラウザでしか動かないので、サーバー側でも止まりません。

export function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  useEffect(() => {
    const update = () => setSize({ width: window.innerWidth, height: window.innerHeight });
    update();
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update);
  }, []);
  return size;
}
describe("useWindowSize", () => {
  it("resize に追従する", () => {
    const { result } = renderHook(() => useWindowSize());
    expect(result.current.width).toBe(window.innerWidth);
    act(() => {
      Object.defineProperty(window, "innerWidth", { value: 500, configurable: true });
      window.dispatchEvent(new Event("resize"));
    });
    expect(result.current.width).toBe(500);
  });
});

テストの実行結果

npx vitest run --reporter=verbose の出力です(比較用の1件は後述)。

 ✓ hooks.test.tsx > useToggle > toggle で反転し、setOn / setOff で固定できる 16ms
 ✓ hooks.test.tsx > usePrevious > 1つ前の値を返す 3ms
 ✓ hooks.test.tsx > useDebounce > delay が過ぎるまで前の値のまま 6ms
 ✓ hooks.test.tsx > useLocalStorage > 保存済みの値を読み、更新を書き込む 2ms
 ✓ hooks.test.tsx > useLocalStorage > 壊れた JSON は初期値に戻す 2ms
 ✓ hooks.test.tsx > useMediaQuery > matchMedia の結果と変更に追従する 2ms
 ✓ hooks.test.tsx > useClickOutside > 外側の mousedown でだけ handler を呼ぶ 5ms
 ✓ hooks.test.tsx > useFetch > 取得した JSON を data に入れる 53ms
 ✓ hooks.test.tsx > useFetch > HTTP エラーは error に入れる 55ms
 ✓ hooks.test.tsx > useFetch > URL を切り替えたら、遅れて届いた前の応答を使わない 175ms
 ✓ hooks.test.tsx > useFetchNaive(比較用) > 遅れて届いた前の応答で上書きされる 174ms
 ✓ hooks.test.tsx > useInterval > delay ごとに呼び、null で止まる 2ms
 ✓ hooks.test.tsx > useEventListener > 登録し、アンマウントで解除する 2ms
 ✓ hooks.test.tsx > useWindowSize > resize に追従する 2ms

 Test Files  1 passed (1)
      Tests  14 passed (14)

AbortControllerで通信の競合を防ぐ

検索語を「a」→「ab」と続けて変えたとき、先に送った通信のほうが遅く返ってくることがあります。中止処理がないと、あとから届いた以前の応答で画面が上書きされます。これを競合(レースコンディション)と呼びます。

比較のために、中止処理のない版を用意しました。

// 比較用:中止処理のない素朴な版
function useFetchNaive<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  useEffect(() => {
    fetch(url).then((res) => res.json()).then(setData);
  }, [url]);
  return data;
}

describe("useFetchNaive(比較用)", () => {
  it("遅れて届いた前の応答で上書きされる", async () => {
    vi.stubGlobal("fetch", delayedFetch({ "/api/slow": 80, "/api/fast": 10 }));
    const { result, rerender } = renderHook(({ url }) => useFetchNaive<{ url: string }>(url), {
      initialProps: { url: "/api/slow" },
    });
    rerender({ url: "/api/fast" });
    await waitFor(() => expect(result.current).toEqual({ url: "/api/fast" }));
    await new Promise((r) => setTimeout(r, 120));
    console.log("naive の最終結果:", JSON.stringify(result.current));
    expect(result.current).toEqual({ url: "/api/slow" });
  });
});
naive の最終結果: {"url":"/api/slow"}

最後に要求したのは /api/fast なのに、80ms後に届いた /api/slow の応答が残りました。同じ条件で useFetch は /api/fast のままです(上のテスト10件目)。

中止処理なし AbortControllerあり
URL切り替え後の最終結果 遅く届いた以前の応答 最後に要求した応答
不要になった通信 最後まで続く 中止される
画面から外れたあとの setState 呼ばれる 呼ばれない

ポイントは2つです。Effectのクリーンアップで controller.abort() を呼ぶこと、そして中止による例外を signal.aborted で見分けて握りつぶすことです。

ルールを外したときのエラー文を再現する

次の3つの書き方を、ESLintと実行の両方で確かめました。

import { useEffect, useState } from "react";

// (A) use で始まらない名前の関数の中でフックを呼ぶ
function toggleState(initial: boolean) {
  const [value, setValue] = useState(initial);
  return [value, () => setValue((v) => !v)] as const;
}

// (B) 条件分岐の中でフックを呼ぶ
export function Profile({ loggedIn }: { loggedIn: boolean }) {
  const [name] = useState("Aoki");
  if (loggedIn) {
    useEffect(() => {
      document.title = name;
    }, [name]);
  }
  return <p>{loggedIn ? name : "guest"}</p>;
}

// (C) 早期 return のあとでフックを呼ぶ
export function Detail({ id }: { id: string | null }) {
  if (id === null) return <p>未選択</p>;
  const [open, toggle] = toggleState(false);
  const [memo] = useState("");
  return <button onClick={toggle}>{open ? id : memo}</button>;
}

ESLintの出力

react-hooks/rules-of-hooks を有効にした npx eslint bad.tsx の結果です。

   5:29  error  React Hook "useState" is called in function "toggleState" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"  react-hooks/rules-of-hooks
  13:5   error  React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render  react-hooks/rules-of-hooks
  24:18  error  React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render  react-hooks/rules-of-hooks

✖ 3 problems (3 errors, 0 warnings)

注目したいのは23行目の toggleState(false) です。早期returnのあとで呼んでいて、中ではuseStateを使っているのに、名前が use で始まらないためESLintはこの行を指摘していません。命名ルールは見た目の約束ではなく、検査ツールが「これはフックだ」と見分けるための目印です。useToggleState に直すと、この行も検査の対象になります。

実行時のエラー文

(B) の Profile を loggedIn={false} で描画したあと loggedIn={true} に変えると、コンソールに次の警告が出て、例外で描画が止まります(警告は、書式の埋め込み部分をコンポーネント名に置き換えた表示形で載せています)。

React has detected a change in the order of Hooks called by Profile. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://react.dev/link/rules-of-hooks

   Previous render            Next render
   ------------------------------------------------------
1. useState                   useState
2. undefined                  useEffect
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: Rendered more hooks than during the previous render.

早期returnのあとにフックがある場合は、フックの数が減る方向でも止まります。useStateを1つ呼んだあとに早期returnし、そのあとにもう1つuseStateがあるコンポーネントで確かめた結果です。

変化 実行時のエラー文
フックが増える(1個 → 2個) Rendered more hooks than during the previous render.
フックが減る(2個 → 1個) Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

なお (C) のように早期returnより前にフックが1つもない場合、今回の環境では行き来しても例外は出ませんでした。エラーが出ないまま動いてしまう形なので、実行時のエラーだけに頼らず、ESLintで書いた時点に見つけるのが確実です。

直し方

うまくいかない書き方 直し方
条件分岐の中でフックを呼ぶ フックは常に呼び、条件はフックの中に入れる(useEffect(() => { if (!loggedIn) return; ... }))
早期returnのあとでフックを呼ぶ フックをすべてreturnより前に移す
条件によってフックごと要らない その部分を別のコンポーネントに分け、コンポーネントごと出し分ける
use で始まらない名前 use +大文字で始まる名前に変える
フックを使っていないのに use を付けている 通常の関数名に戻す(どこでも呼べるようになる)

サーバー側の描画でwindowが見つからないときの対処

Next.jsなどサーバーでも描画する構成では、コンポーネントの関数はまずNode.js上で実行されます。Node.jsには window も localStorage もないため、描画中に触ると止まります。

Next.js本体でのビルドは今回は未実行です。ここでは同じ状況を、Node.js 22.22.2 上で react-dom/server の renderToString を呼んで再現しました。

import { useState } from "react";
import { renderToString } from "react-dom/server";
import { useLocalStorage, useMediaQuery, useWindowSize } from "./hooks";

function useWindowWidthUnsafe() {
  const [width] = useState(window.innerWidth); // サーバーには window がない
  return width;
}
function useThemeUnsafe() {
  const [theme] = useState(() => localStorage.getItem("theme") ?? "light");
  return theme;
}
function Unsafe1() { return <p>{useWindowWidthUnsafe()}</p>; }
function Unsafe2() { return <p>{useThemeUnsafe()}</p>; }
function Safe() {
  const [theme] = useLocalStorage("theme", "light");
  const wide = useMediaQuery("(min-width: 768px)");
  const { width } = useWindowSize();
  return <p>{theme} / wide={String(wide)} / width={width}</p>;
}
for (const [name, el] of [["Unsafe1", <Unsafe1 />], ["Unsafe2", <Unsafe2 />], ["Safe", <Safe />]] as const) {
  try { console.log(name, "=>", renderToString(el)); }
  catch (e) { console.log(name, "=>", (e as Error).name + ": " + (e as Error).message); }
}
Unsafe1 => ReferenceError: window is not defined
Unsafe2 => ReferenceError: localStorage is not defined
Safe => <p>light<!-- --> / wide=<!-- -->false<!-- --> / width=<!-- -->0</p>

この記事の3本(useLocalStorage・useMediaQuery・useWindowSize)は、サーバー側でも初期値を返して描画を終えられました。使っている方法は3種類です。

方法 書き方 使ったフック
typeof window で分岐する if (typeof window === "undefined") return initialValue; useLocalStorage
Effectの中で読む useEffect(() => { setSize(...window...) }, []) useWindowSize
サーバー用の値を別に渡す useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) useMediaQuery

サーバーが返した初期値と、ブラウザで読んだ実際の値が違うと、表示が一瞬切り替わったり、ハイドレーション(サーバーが作ったHTMLにReactを結び付ける処理)の不一致として警告されたりします。最初の描画はサーバーと同じ値にそろえ、Effectで実際の値に更新する形にすると落ち着きます。また、Next.jsのApp Routerでは、stateやEffectを使うフックを呼ぶコンポーネントのファイル先頭に "use client" が必要です(一般に知られた仕様で、今回は未実行です)。

カスタムフックを作るかどうかの判断表

状況 判断 理由
同じstateとEffectの組み合わせが2か所以上にある 作る 直す場所が1つになる
コンポーネントのEffectが長く、何をしているか読みにくい 作る 名前が説明になる(useClickOutside など)
ブラウザのAPI(storage、matchMedia、イベント)を扱う 作る サーバー対応や解除処理を1か所に閉じ込められる
ロジックだけをテストしたい 作る renderHook で画面なしにテストできる
フックを使わない計算(日付の整形、金額の合計) 作らない 通常の関数で十分。use を付けない
1か所でしか使わず、3〜4行で済む 急がない コンポーネントに置いたままのほうが読みやすい

よくある質問

Q. カスタムフックと通常の関数は何が違いますか? A. 中でフック(useStateやuseEffectなど)を呼んでいるかどうかです。呼んでいるなら名前を use で始めてカスタムフックにし、呼んでいないなら通常の関数にします。

Q. 同じカスタムフックを2つのコンポーネントで使うと、stateは共有されますか? A. 共有されません。呼び出すたびに別々のstateが作られます。共有したい場合は、stateを親に持ち上げるか、Contextや状態管理ライブラリを使います。

Q. カスタムフックから返す関数はuseCallbackで包むべきですか? A. 包んでおくのがおすすめです。呼び出し側がEffectの依存配列に入れたり、メモ化した子に渡したりしても、参照が変わらず扱いやすくなります。この記事のフックもその形にしています。

Q. renderHookでテストするとき、なぜactが必要ですか? A. stateの更新をReactに反映させてから結果を確かめるためです。act の中で更新を起こすと、抜けた時点で再レンダリングとEffectの実行が済み、result.current が最新になります。非同期の結果は waitFor で待ちます。

まとめ

  • カスタムフックは「useで始まり、中でフックを呼ぶ関数」。 state・Effect・返す値をひとまとめにして切り出すだけで作れる。
  • 10本(useToggle / usePrevious / useDebounce / useLocalStorage / useMediaQuery / useClickOutside / useFetch / useInterval / useEventListener / useWindowSize)を、型付きでそのまま使える形にした。 テスト14件が通ることを確認済み。
  • use 命名は検査ツールの目印。 外すと、ESLintがその関数の呼び出しを検査できなくなる。
  • 条件分岐や早期returnのあとでフックを呼ぶと、「Rendered more hooks…」「Rendered fewer hooks…」で止まる。 条件はフックの中に入れる。
  • 通信するフックはAbortControllerで前の通信を中止する。 中止しないと、遅れて届いた以前の応答で上書きされる。
  • サーバーでも描画する構成では、描画中に window や localStorage を触らない。 typeof window の分岐、Effect、useSyncExternalStoreのいずれかで対応する。
  • ロジックのテストは renderHook と act、時間は偽のタイマー、通信は fetch の差し替えで書ける。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React カスタムフックの作り方|useFetch等コピペ用10選」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?