- React
React カスタムフックの作り方|useFetch等コピペ用10選
カスタムフックとは、名前が「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つです。
- stateとそれを操作する処理をひとまとめにして、関数に移す。
- 関数名を
useで始める。 - コンポーネントが必要とする値と関数だけを返す。
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期連続の黒字決算。