ルートファイルの凝集度を上げる React Router v7 リファクタリング
これはなに?
前回の記事では、購入者・出品者・管理者のように異なるロールの機能を「同じカテゴリだから」とまとめてしまう問題を扱いました。コロケーション構成でルートを機能単位に分けることで、条件分岐が散らばらず保守しやすくなるという話でした。
今回は、その「分けたルートの中身」に注目します。ルートを適切に分割しても、ひとつのルートファイルの中で関係ない処理が混在していると、結局コードの見通しが悪くなってしまいます。
この記事では、ありがちなルートファイルを段階的にリファクタリングしながら、凝集度を上げるとはどういうことかを体感してもらいます。
本記事の内容を Notebook LM でスライド化したものも公開しています。視覚的に概要を把握したい方はこちらもどうぞ。
本記事のガイドラインを Claude Code などの AI アシスタント向けに CLAUDE.md へ記載する例を付録に用意しています。
凝集度の歴史と本質
凝集度という考え方は、1970年代の大型コンピュータ時代に生まれました。IBMの技術者だったConstantine、Myers、Stevensが、ソフトウェアの変更に強い構造を求めて整理したものが始まりです。当時のFORTRANやPL/Iでは、共有データや分岐に依存した大規模プログラムが一般的で、一度コードが崩れると修正が連鎖的に広がり、手戻りがきわめて大きくなっていました。そこで彼らは、多数の実務的な観察をもとに、モジュール内部の処理どうしがどれだけ一貫した目的を共有しているかという軸で段階的な分類を作り、強いまとまりを持つ構造がエラーや変更に耐えるという指針を提示しました。
その後、凝集度は数値化や理論化を試みる研究に広がり、オブジェクト指向が一般化した1990年代には、クラス設計を診断するための指標として再解釈されていきます。単一責務という考え方が整理され、クラス内のメソッドがどれだけ同じ概念を扱っているかが重要視されるようになるにつれて、古典的な分類は背景に回り、設計原則の一部として吸収されていきました。
関数型言語やフロントエンド技術が広がった2000年代以降は、凝集度を「ひとまとまりの概念的境界をどう保つか」というより抽象的な視点で捉えるようになります。ReactのようにUIを小さな部品として組み上げる枠組みでは、コンポーネントやカスタムフックがひとつのはっきりした役割を持っているかどうかが、そのまま凝集度の良し悪しになります。古い分類を直接当てはめるというより、初期の発想を受け継いだ「境界の中に自然なまとまりをつくる」という思想が形を変えて生きているのです。
凝集度の段階
凝集度には正式な分類名がありますが、「偶発的凝集」「時間的凝集」「手続き的凝集」「機能的凝集」といった用語は、1970年代の文脈で名付けられたものです。現代のフロントエンド開発でこれらの言葉を聞いても、何を指しているのかピンとこないのは当然です。
この記事では、コードを見たときの「あるある感」に直結する呼び方を使います。凝集度が低い順に並べると以下のようになります。
ここに置いとくか型(偶発的凝集): たまたま同じファイルにいるだけの状態です。プロフィール編集ページに日付フォーマット関数が置かれているような場合がこれにあたります。
似てるから型(論理的凝集): 「同じカテゴリだから」「見た目が似ているから」という理由でまとめてしまう状態です。購入者・出品者・管理者向けの商品詳細を1つのコンポーネントに詰め込むような場合がこれにあたります。この問題は前回の記事で詳しく扱っています。
同時にやるから型(時間的凝集): 同じタイミングで実行されるから一緒にいる状態です。ページ表示時にデータ取得とアナリティクス送信を両方やるような場合です。
一連の流れだから型(手続き的凝集): 処理の流れで連鎖しているけど目的はバラバラな状態です。バリデーション→保存→監査ログ→キャッシュクリアを全部 action に詰め込むような場合です。
これだけやる型(機能的凝集): ひとつの役割に集中している状態です。これが目指すべきゴールになります。
では、実際のコードを改善しながら、この階段を上っていきましょう。
出発点:ありがちなプロフィール編集ページ
プロフィール編集機能を急いで作ると、こんな感じのコードになりがちです。
// routes/profile.tsx
import { Form, redirect } from "react-router";
import type { Route } from "./+types/profile";
// なぜかここにいるユーティリティ
export const formatDate = (d: Date) => d.toLocaleDateString("ja-JP");
export const slugify = (s: string) => s.toLowerCase().replace(/\s+/g, "-");
export async function loader() {
const profile = await fetch("/api/profile").then(r => r.json());
await fetch("/api/analytics", {
method: "POST",
body: JSON.stringify({ event: "profile_view" }),
});
return { profile };
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = form.get("name") as string;
const email = form.get("email") as string;
if (!name.trim()) return { error: "名前は必須です" };
if (!email.includes("@")) return { error: "メールアドレスが不正です" };
await fetch("/api/profile", {
method: "PUT",
body: JSON.stringify({ name, email }),
});
await fetch("/api/audit", {
method: "POST",
body: JSON.stringify({ action: "profile_update" }),
});
await fetch("/api/cache/clear", { method: "POST" });
return redirect("/");
}
export default function ProfilePage({
loaderData,
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>プロフィール編集</h1>
<Form method="post">
<input name="name" defaultValue={loaderData.profile.name} />
<input name="email" defaultValue={loaderData.profile.email} />
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
<button type="submit">保存</button>
</Form>
</div>
);
}
このコードには3つの問題が混在しています。ひとつずつ解消していきましょう。
ステップ1:「ここに置いとくか」を解消する
formatDate と slugify はプロフィール編集と何の関係もありません。たまたまこのファイルを開いていたから置かれただけです。
// utils/format.ts に移動
export const formatDate = (d: Date) => d.toLocaleDateString("ja-JP");
export const slugify = (s: string) => s.toLowerCase().replace(/\s+/g, "-");
ルートファイルからこれらを削除するだけで、「このファイルは何?」という問いに答えやすくなります。
ステップ2:「同時にやるから」を解消する
loader の中でプロフィール取得とアナリティクス送信が同居しています。「ページ表示時にやるから」という理由で一緒にいますが、目的が違います。
export async function loader() {
const profile = await fetch("/api/profile").then(r => r.json());
return { profile };
}
アナリティクスはこのルート固有の関心事ではありません。どのページでも同じように送信したいはずなので、親レイアウトやミドルウェアで一元管理するほうが適切です。
ステップ3:「一連の流れだから」を解消する
action の中で、バリデーション→保存→監査ログ→キャッシュクリア→リダイレクトと、目的の違う処理が連鎖しています。
まず、バリデーションを分離します。
// features/profile/validation.ts
export function validateProfile(name: string, email: string) {
if (!name.trim()) return { error: "名前は必須です" };
if (!email.includes("@")) return { error: "メールアドレスが不正です" };
return null;
}
次に、監査ログとキャッシュクリアを action から外します。これらはプロフィール保存の「結果として起きるべきこと」であり、APIサーバー側の責務です。
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = form.get("name") as string;
const email = form.get("email") as string;
const validationError = validateProfile(name, email);
if (validationError) return validationError;
const res = await fetch("/api/profile", {
method: "PUT",
body: JSON.stringify({ name, email }),
});
if (!res.ok) return { error: "保存に失敗しました" };
return redirect("/");
}
最終形:「これだけやる」状態
// routes/profile.tsx
import { Form, redirect } from "react-router";
import type { Route } from "./+types/profile";
import { validateProfile } from "../features/profile/validation";
export async function loader() {
const profile = await fetch("/api/profile").then(r => r.json());
return { profile };
}
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const name = form.get("name") as string;
const email = form.get("email") as string;
const error = validateProfile(name, email);
if (error) return error;
const res = await fetch("/api/profile", {
method: "PUT",
body: JSON.stringify({ name, email }),
});
if (!res.ok) return { error: "保存に失敗しました" };
return redirect("/");
}
export default function ProfilePage({
loaderData,
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>プロフィール編集</h1>
<Form method="post">
<input name="name" defaultValue={loaderData.profile.name} />
<input name="email" defaultValue={loaderData.profile.email} />
{actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
<button type="submit">保存</button>
</Form>
</div>
);
}
「このファイルは何をするものですか」と聞かれたら、「プロフィールを取得して、編集して、保存するページです」と即答できます。loader はプロフィール取得だけ、action はバリデーションと保存だけ、コンポーネントはフォーム表示だけ。すべてがひとつの目的を支えています。
まとめ
前回の記事では、異なる機能をルート単位で分けることの大切さを見ました。今回は、分けたルートの中身をさらに整理することで、凝集度を高める方法を見てきました。
1970年代に生まれた凝集度という考え方は、技術スタックが変わっても本質は同じです。コンポーネントに一貫した責務を持たせ、不要な依存を外に追い出すという実践が、当時と同じ目的──変更しやすく壊れにくい構造──に向かっています。
コードを書いていて「このファイルは何をするものか」に答えづらいと感じたら、今回解消した3つのパターン(偶発的・時間的・手続き的凝集)のどれかに陥っていないか、振り返ってみてください。
参考
- 機能的凝集とコロケーションで保守しやすい React Router v7 コンポーネント設計 - 本記事の前編
- オブジェクト指向のその前に-凝集度と結合度 - 凝集度と結合度の基礎を解説したスライド
- Structured Design (Stevens, Myers, Constantine, 1974) - 凝集度の概念が初めて体系化された論文
付録: CLAUDE.md 追記例
前回の記事のガイドラインに加えて、以下を CLAUDE.md に追記することで、AI アシスタントにもルートファイル内の凝集度を意識させることができます。
### Route File Cohesion
Keep each route file focused on a single purpose.
**Don't mix unrelated utilities:**
- Move generic utilities (formatDate, slugify, debounce) to `utils/` or `lib/`
- Route files should only contain route-specific code
**Don't mix by timing:**
- Loaders should only fetch data needed for this specific route
- Move analytics, logging to parent layouts or middleware
- Each loader/action should have one clear responsibility
**Don't chain unrelated procedures:**
- Extract validation to `features/{feature}/validation.ts`
- Keep audit logs, cache invalidation on the API server side
- Actions should: validate → save → redirect (nothing else)
**Test: "What does this file do?"**
If you can't answer in one sentence, the cohesion is too low.
Discussion