375 Views
November 28, 25
スライド概要
React Router v7 でつくるアプリのルートファイル内で凝集度を高めるための具体的なリファクタリング手順を解説しています。
凝集度とは、ひとつのモジュール内の要素がどれだけ強く関連し合っているかを示す尺度であり、記事ではファイルが単一の役割に集中する機能的凝集を目標としています。
解説は、無関係なユーティリティが混在する偶発的凝集や、実行タイミングが同じという理由で集められた時間的凝集など、低凝集度のパターンを定義することから始まります。
一般的なプロフィール編集ページのコードを例として使用し、段階的にリファクタリングを進めながら、分析ログやキャッシュクリアといった主要な目的と異なる処理を分離する方法を示しています。
これらの工程を通じて、データの取得と保存という核となる機能だけが残された最終形を提示し、コードの保守性と可読性が向上すると説明しています。
凝集度を高めるリファクタリングの旅 React Router v7における、変更に強く壊れにくいルート設計術
物語の始まり:よくあるプロフィール編集ページ
// 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("/");
}
// ... Component code omitted for brevity
一見、問題なく動作するこの
コード。しかし、保守の旅は
ここから始まります。
中心的な問い:
「このファイルは何をする
ものですか?」
この問いに、あなたは即答
できますか?
見えざる敵:低凝集度という問題 ファイルが多くの無関係な責務を抱え込むと、コードの意図が曖昧になります。 これが「低凝集度」です。 profile.tsx データ保存 処理 監査ログ 監査ログ アナリティクス送信 汎用的な ユーティリティ 関数 フォームの バリデーション データ取得 • 責務1: 汎用的なユーティリティ関数 • 責務2: ページ表示時のデータ取得 • 責務3: アナリティクス送信 • 責務4: フォームのバリデーション • 責務5: データ保存処理 • 責務6: 監査ログとキャッシュクリア
古くからの知恵:「凝集度」という設計指針 凝集度は1970年代、IBMの技術者たちがソフトウェアの変更に強い構造を求めて 体系化した概念です。その本質は、技術スタックが変わっても不変です。 「モジュール内部の処理どうしが、 どれだけ一貫した目的を共有しているか」 1970s Structured Design: Constantine, Myers, Stevens 1990s クラス設計の指標 / 単一責務 2020s コンポーネント / フックの役割
倒すべき敵を知る:低凝集度の3つの型 専門用語(偶発的、時間的、手続き的凝集)ではなく、「あるある感」で理解しましょう。 ここに置いとくか型 たまたま同じファイルにいる だけの、無関係な処理。 同時にやるから型 実行タイミングが同じというだ けで、目的が異なる処理。 一連の流れだから型 処理が連鎖しているが、それぞ れが異なる責務を持つ状態。 目指すゴール:これだけやる型(機能的凝集)
ステップ1:最初の敵、「ここに置いとくか型」
`formatDate` と `slugify` はプロフィール編集と何の関係もありません。たまたまこのファイルを
開いていたから置かれただけです。
routes/profile.tsx
MISPLACED
formatDate
slugify
Profile Logic
// routes/profile.tsx
// なぜかここにいるユーティリティ
export const formatDate = (d: Date) =>
d.toLocaleDateString("ja-JP");
export const slugify = (s: string) =>
s.toLowerCase().replace(/\s/g, "-");
}
export async function loader() {
// ...
}
解決策:本来あるべき場所へ移す
汎用的なユーティリティは`utils` ディレクトリに切り出します。
utils/format.ts
// utils/format.ts
export const formatDate = (d: Date) =>
d.toLocaleDateString("ja-JP");
export const slugify = (s: string) =>
s.toLowerCase().replace(/\s/g, "-");
routes/profile.tsx
Profile Logic
utils/format.ts
15
成果:「このファイルは何?」という問いに、少し答えやすくなった。
ステップ2:次の敵、「同時にやるから型」
`loader` の中で、プロフィール取得とアナリティクス送信が同居しています。
「ページ表示時に実行する」という共通点はありますが、目的が全く異なります。
routes/profile.tsx
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 };
}
Page Load
責務:データ取得
責務:アナリティクス
SIDE EFFECT?
解決策:責務を上位のレイヤーに委ねる アナリティクスは、このルート固有の関心事ではありません。親レイアウトやミドルウェアで 一元管理するほうが適切です。 export async function loader() { const profile = await fetch("/api/profile").then(r => r.json()); return { profile }; } Parent Layout 責務:アナリティクス routes/profile.tsx ... ... Page Load 責務:データ取得 成果:`loader` はプロフィール取得という単一の責務に集中できた。
ステップ3:最後の敵、「一連の流れだから型」
`action` 関数が、バリデーション、保存、監査ログ、キャッシュクリアといった、目的の違う処理の
長い連鎖になっています。
export function action() => {
if (!formData.name) return badRequest();
if (formData.age < 18) return badRequest();
const profile = await fetch("/api/profile", { method: "POST", body: formData });
await fetch("/api/audit", { method: "POST", body: JSON.stringify({ event: "profile_update" }) });
await fetch("/api/cache/clear", { method: "POST" });
return redirect("/profile");
}
Form
Submit
Validate
Save Profile
Log Audit Trail
Log Audit Trail
Clear Cache
解決策:分離と委譲
バリデーションロジックを分離
バリデーションは再利用可能な純粋な関数として
切り出します。
// features/profile/validation.ts
export function validateProfile(name: string, email:
string) {
if (!name.trim()) return { error: "名前は必須です" };
if (!email.includes("@")) return { error: "メールアドレス
が不正です" };
return null;
}
副作用をAPIサーバーに委譲
監査ログとキャッシュクリアは、プロフィール保
存の『結果として起きるべきこと』であり、API
サーバー側の責務です。
UI Action
PUT /api/profile
API
Server
1. Save to DB
2. Write Audit Log
3. Invalidate Cache
旅の終わり:揺ぎない単一責務
最終形:「これだけやる」状態。ファイルが何をするか、一文で説明できます。
// 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) {
// ... Component JSX
}
「プロフィールを取得して、編集
して、保存するページです」
• loader: プロフィール取得だけ
• action: バリデーションと保存だけ
• Component: フォーム表示だけ
profile.tsx
loader
action
Component
この旅からの学び 私たちは、よくあるコードから出発し、3つの低凝集パターンを解消しました。 低凝集の型 解決策 ここに置いとくか型 関連のない処理を外部ファイルに分離 同時にやるから型 目的の異なる処理を適切なレイヤーに分離 一連の流れだから型 異なる責務を専門のモジュールやサーバーに委譲 目指す目的は1970年代から同じ:変更しやすく、壊れにくい構造を作ること。
あなたのコードを導く、ただ一つの問い 「このファイルは 何をするものか?」 この問いに答えづらいと感じたら、あなたのリファクタリングの旅が始まる合図です。