カスタムフックへの抽出基準 — 「コンポーネントには hooks 以外書かない」は正しいか
TL;DR
- 「コンポーネントにはカスタムフック以外書くべきでない」を一律ルールにすることは、React 公式もコミュニティも支持していない。公式は「早すぎる抽象化に抵抗せよ」。
- 抽出の判断軸は「再利用するか」ではなく「コンポーネントのコードが意図(what)を語り、実装(how)を隠せるか」。
- 生の
useEffect(外部システムとの同期)は積極的な抽出対象。時間が経てば Effect の大半はカスタムフック内に収束するのが公式の想定。 - JSX に近い単純な UI state(開閉フラグ、controlled input、
disabled制御など)はコンポーネント直書きが正当。 - 判定法: 具体的な名前が即座に付くか。名前が付けられないなら結合が強すぎてまだ抽出の準備ができていない。
useMount等の汎用ライフサイクルラッパーは禁止パターン。フックである必然性がないもの(純粋関数・設定共有)はフックにしない。- テスタビリティは抽出理由になるが、条件付き。RTL 陣営の既定路線は「単発利用のフックは、使っているコンポーネント経由でテストする」。コンポーネント経由で振る舞いに到達できない場合(jsdom で描画不能な外部ライブラリ、複雑な非同期・状態遷移)に初めてテスタビリティが抽出の決め手になる。
このドキュメントの射程
- 「どこまでカスタムフックにすべきか」「コンポーネントは JSX + カスタムフック呼び出しだけにすべきか」という設計判断。
- テスタビリティ(フック単体テスト vs コンポーネント経由テスト)を抽出基準にどう組み込むか。
- 対象: React 関数コンポーネントにおけるロジック抽出の基準。特定ライブラリではなく一般論。
原因(なぜ「全部フックへ」に流れがちか)
- Container / Presentational パターンのフック版として「ビジネスロジックはすべてカスタムフックへ、コンポーネントはレンダリングのみ」という規範が一部に存在する(
useStateやuseCallbackをプレゼンテーションコンポーネントで使うこと自体をエラーにする ESLint ルールを作っている例まである)。 - コンポーネントが長くなること自体を問題と捉えがちだが、公式は「関数コンポーネントができることが増えた以上、平均的なコンポーネントは長くなる。これは正常で、すぐにフックに分割しなければならないと感じる必要はない」と明言しており、長さ単独は抽出の根拠にならない。
- 「フックにすればテストしやすくなる」という直感も後押しするが、後述の通りこれは条件付きでしか成立しない。
解決(判断基準)
抽出すべきシグナル
- 複数コンポーネントで同じ
useState/useEffectの組み合わせが重複している(機械的再利用)。 - 外部システムとの同期を含む Effect(購読、ブラウザ API、WebSocket 等)。抽出するとデータフローが明示的になり(url を入れると data が出てくる、のように)、コンポーネントは「オンライン状態を使いたい」という意図だけを語り、「ブラウザイベントを購読する」という実装が隠れる。他の開発者が Effect に不要な依存を追加してしまう事故も防げる。
- 具体的な名前が即座に付く(
useOnlineStatus,useChatRoom,useUserProfile)。コードを書かない人でも「何を受け取り、何を返すか」を推測できる名前が理想。外部システム同期ならuseMediaQueryのように技術的ジャーゴンを含む名前でもよい(そのシステムに詳しい人に伝わるなら可)。 - コンポーネント経由では振る舞いに到達できない(テスタビリティ。詳細は後述)。
抽出すべきでない・慎重にすべきケース
- 名前が付けられない — Effect がコンポーネントの他のロジックと結合しすぎているサイン。抽出の準備ができていない。
useMount的な汎用ライフサイクルラッパー — React のパラダイム(ライフサイクルではなく同期)に合わない。公式が明確に禁止。依存をリンタから隠す副作用もある。目的を具体的ユースケース単位に保つ。- 数行の単純な UI state — 開閉フラグ、controlled input、送信ボタンの
useState+disabledなど。抽出はノイズで、TypeScript ならパラメータ・戻り値の型定義コストも増える。再利用性がなくコード量も少ないロジックまで抽出すると、コンポーネントが断片の寄せ集めになり可読性が逆に落ちる。 - フックである必然性がないもの — フックを呼ばない純粋関数はただの関数に(
useプレフィックスを付けない。付けるとリンタが不要な制約を課す)。設定の共有(例: query options)はプレーンオブジェクトに。カスタムフックはコンポーネント / 他フックの中でしか呼べないため、サーバー・route loader・イベントハンドラでのプリフェッチ等フック外の環境で再利用できず、特定実装(useQueryなど)にロックインする(TkDodo の指摘)。
コード例: 抽出がノイズになる例 / 正当な例
// ❌ 抽出がノイズになる例: 単純な UI state を無理にフック化
// - 再利用しない・数行・名前を付けても情報が増えない
// - 型定義と間接参照が 1 層増えるだけ
function useDialogOpen() {
const [open, setOpen] = useState(false);
return { open, openDialog: () => setOpen(true), closeDialog: () => setOpen(false) };
}
// ✅ コンポーネント直書きで十分
function UserProfileDialog() {
const [open, setOpen] = useState(false);
// ...
}
// ✅ 抽出が正当な例: 外部システムとの同期
// - 「どうやるか」(イベント購読・クリーンアップ)がフック内に隠れる
// - コンポーネント側には「何をしたいか」だけが残る
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
function StatusBar() {
const isOnline = useOnlineStatus(); // 意図だけが残る
return <h1>{isOnline ? "✅ Online" : "❌ Disconnected"}</h1>;
}
「全部フックへ」の実害(過剰抽出の症状)
- state・副作用・前提が呼び出し側から見えない「見えない容器」になる。state はどこにあるのか、何が再レンダーを引き起こすのか、fetch / mutate / cache のどれをするのかが呼び出し側から読めなくなり、「読める複雑さ」を「隠れた複雑さ」と交換してしまう。
- この痛みは開発中ではなく後から(障害対応などプレッシャー下で)顕在化する、という報告が特徴的。対処としては、カスタムフックを「振る舞いの容器」ではなく機械的再利用に限定し、実際の state やビジネス判断はコンポーネントツリーの近くに置く方針が取られている。
テスタビリティを抽出基準にどう組み込むか
「フックにした方がテストしやすい」は半分正しい。ただし主流派(React Testing Library 陣営)の整理では、フック単体テストはデフォルトではなく例外である。
ツールの使い分け基準(作者自身の明記)
フックを普通の関数として呼ぶと Invalid hook call. Hooks can only be called inside the body of a function component になるため、単体テストには renderHook(現在は @testing-library/react v13+ に統合)が必要になる。重要なのは、元の react-hooks-testing-library の README 自体が使い分けを明記していること:
- 使うべき場合: (a) コンポーネントに直接紐づかないカスタムフックのライブラリを書いている、(b) コンポーネント経由の操作ではテストしにくい複雑なフックがある。
- 使うべきでない場合: (a) フックがコンポーネントと並んで定義されそこでしか使われていない、(b) そのフックを使うコンポーネントをテストするだけで簡単にテストできる。
なぜ「コンポーネント経由」が既定なのか(実装詳細テスト回避)
Kent C. Dodds の論点: コンポーネントが Hooks で実装されているかクラスで実装されているかは実装詳細であり、テストがそれを暴く書き方だとリファクタリングでテストが壊れる。この論理をフック抽出に当てはめると、「ロジックがコンポーネント内にあるかカスタムフック内にあるか」も実装詳細である。つまり:
- ユーザー観察可能な振る舞いをコンポーネント経由でテストしていれば、後からフックに抽出してもテストは 1 行も変わらない(リファクタ耐性)。
- 逆にフック単体テストは「そのフックが正しい」ことしか保証せず、コンポーネントとの結線ミス(戻り値の渡し忘れ、ハンドラの付け間違い)は検出できない。結局コンポーネントテストは必要になる。
したがって「テストのために抽出する」は、コンポーネントテストで代替できる限り根拠が弱い。
テスタビリティが決め手になる正当なケース
コンポーネント経由では振る舞いに到達できない場合、抽出が唯一のテスト手段になる。
ケース 1: jsdom で描画できない外部ライブラリ依存。 典型例が react-map-gl(Mapbox)。ブラウザでしか動かないためコンポーネントを Testing Library でレンダーできない。コンポーネント自体はテスト不能のままだが、ロジック(マーカー選択、ポップアップ開閉など)をフックに抽出すれば renderHook でビジネス期待に合致するかをテストできる。
// MapPage 直書きだと <Map /> が jsdom で落ちるのでテスト不能。
// ロジックだけ抽出すればテスト可能になる。
function useMarkerSelection(markers: Marker[]) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const select = (id: string) =>
setSelectedId((prev) => (prev === id ? null : id)); // 再クリックで閉じる
const selected = markers.find((m) => m.id === selectedId) ?? null;
return { selected, select };
}
// useMarkerSelection.test.ts
test("同じマーカーを再選択すると解除される", () => {
const markers = [{ id: "a" }, { id: "b" }];
const { result } = renderHook(() => useMarkerSelection(markers));
act(() => result.current.select("a"));
expect(result.current.selected?.id).toBe("a");
act(() => result.current.select("a"));
expect(result.current.selected).toBeNull();
});
ケース 2: 複雑な非同期・状態遷移ロジック。 ローディング → 成功 / 失敗 → リトライのような遷移を DOM 操作経由で全パス網羅するのは冗長になりやすい。renderHook は rerender(依存変化のテスト)、unmount(クリーンアップのテスト)、非同期待機ユーティリティを備えており、遷移を直接検証できる。React 公式(legacy)も「reducer は分離してテストするのに非常に便利で、複雑な更新ロジックの表現にスケールする」とロジック分離のテスト面の利点を認めている。なお、さらに複雑な純ロジックは reducer / 純粋関数まで落とせば renderHook すら不要になる(普通の関数としてテストできる)ので、「フック単体テスト」は分離の終着点ではなく中間形態とも言える。
ケース 3: フックライブラリを書いている。 useDebounce や useLocalStorage のような、特定コンポーネントを持たない汎用フック。ホストとなるコンポーネントが存在しないので単体テストが自然な形。
判断のまとめ(テスト観点)
判断軸は「テストしやすいか」ではなく「振る舞いに到達する最短経路がどこにあるか」。
| 状況 | テスト手段 | 抽出は必要か |
|---|---|---|
| 単発利用・コンポーネント経由で操作可能 | コンポーネントテスト(RTL) | 不要(抽出してもテストは変わらない) |
| jsdom で描画不能な外部ライブラリ依存 | フック抽出 + renderHook | 必要(唯一の到達経路) |
| 複雑な非同期・状態遷移 | フック抽出 + renderHook、可能なら reducer / 純関数へ | 有効(DOM 経由の網羅より簡潔) |
| 汎用フックライブラリ | renderHook | そもそも抽出済みの形態 |
まとめ
- 一律「hooks 以外書かない」は過剰。Effect と名前の付く複雑な state ロジックは抽出し、JSX に近い単純な UI state は直書きすると、結果的に公式の言う「Effect の大半はカスタムフック内」に自然に収束する。判定は「具体的な名前が付くか」「意図と実装を分離できるか」に加え、「コンポーネント経由で振る舞いに到達できるか」。到達できるならコンポーネントテストが既定で、抽出はテスト理由にならない。到達できないとき、テスタビリティは正当な抽出の決め手になる。
参考
- React 公式「Reusing Logic with Custom Hooks」: https://react.dev/learn/reusing-logic-with-custom-hooks
- React 公式(legacy)「Building Your Own Hooks」(早すぎる抽象化への警告・reducer のテスト容易性): https://legacy.reactjs.org/docs/hooks-custom.html
- react-hooks-testing-library README(when to use / when not to use): https://github.com/testing-library/react-hooks-testing-library
- Kent C. Dodds「Testing Implementation Details」: https://kentcdodds.com/blog/testing-implementation-details
- Kent C. Dodds「How to test custom React hooks」: https://kentcdodds.com/blog/how-to-test-custom-react-hooks
- Kent C. Dodds「5 Tips to Help You Avoid React Hooks Pitfalls」: https://kentcdodds.com/blog/react-hooks-pitfalls
- Arnaud Langlade「How to use custom React hook to increase application testability」(Mapbox の例): https://www.arnaudlanglade.com/software-testing-react-hook/
- TkDodo「Creating Query Abstractions」(フックによるロックインの指摘): https://tkdodo.eu/blog/creating-query-abstractions
- Felix Gerschau「Separation of concerns with React hooks」: https://felixgerschau.com/react-hooks-separation-of-concerns/
- 過剰抽出の実害報告(Medium): https://medium.com/@rahulkengale1110/custom-hooks-are-making-your-react-code-worse-and-most-developers-dont-realize-it-540579277248