「743行の巨大Reactカスタムフック」を象徴する複雑なコードブロックが、中央のエンジニアによって「STAGE MGMT」や「API CALLS」など8つのモジュールに段階的に分割・整理されていく様子を描いています。

はじめに

React のカスタムフックが 743 行に膨れ上がったとき、正直「まだ読めるし、動いているから大丈夫」と思っていました。

しかし、新機能の追加やバグ修正のたびに該当箇所を探すだけで数分かかり、テストを追加しようにもモック対象が多すぎて手が止まる——この状態が続くと、開発速度が目に見えて落ちていきます。

本記事では、743 行のモノリシックなカスタムフック useProposalGeneration8 つの専門モジュールに段階的に分割 したリファクタリングの全過程を紹介します。
さらに、自動コードレビューで指摘された 3 つの改善事項への対応を通じて得た、型安全性・保守性向上のベストプラクティスも解説します。

こんな人におすすめ

  • React カスタムフックが肥大化してきて、リファクタリングの方針に悩んでいる方
  • as 型アサーションの使いどころに迷っている TypeScript 開発者
  • テストカバレッジを維持しながらリファクタリングを進める手法を探している方
  • 自動コードレビューツールの指摘をどう活かすか、具体的な事例を知りたい方

Before: 743 行のモノリシックフック

リファクタリング前の useProposalGeneration は、以下の責務をすべて 1 ファイルに詰め込んでいました。

  • 生成ステージの状態管理(preparing → generating → complete)
  • テキスト生成の API 呼び出し
  • 画像生成の非同期フロー
  • キャッシュの読み書き
  • エラーハンドリング(クォータ・認証・WAF・ネットワーク)
  • キャンセル処理

1 ファイル 743 行で「動いてはいる」のですが、問題は修正コストです。
たとえばエラーハンドリングのロジックを変更したいだけなのに、画像生成コードやキャッシュ処理を読み飛ばしながら該当箇所を探す必要がありました。

リファクタリング戦略: 責務ごとに8モジュール分割

分割の方針は「1 つのフック = 1 つの責務」です。
以下のように責務ごとにファイルを分けました。

hooks/useProposalGeneration/
├── index.ts                    # 公開 API(re-export)
├── types.ts                    # 型定義(GenerationStage, ErrorType 等)
├── constants/messages.ts       # エラーメッセージ定数
├── useGenerationStage.ts       # ステージ状態管理
├── useTextGeneration.ts        # テキスト生成 + Proposal 構築
├── useImageGenerationFlow.ts   # 画像生成の非同期フロー
├── useCancellation.ts          # キャンセル処理
└── useProposalGeneration.ts    # オーケストレーター(各フックを統合)

ポイント: index.ts でモジュール利用者が必要な部分だけを選択的にインポートできるようにしています。

export type { GenerationStage, ErrorType, ProposalError } from "./types";
export { determineErrorType, createProposalFromText } from "./useTextGeneration";
export { useProposalGeneration } from "./useProposalGeneration";

バンドルサイズの最適化にもつながりますし、テスト時にはサブフック単体をインポートして検証できます。

改善 1: エラーメッセージの一元管理

自動コードレビューで最初に指摘されたのが、ハードコードされたエラーメッセージでした。
フック内の各所に散在していた文字列リテラルを constants/messages.ts に集約しました。

export const ERROR_MESSAGES = {
  QUOTA_EXHAUSTED_DETAIL:
    "翌月まで画像の生成はご利用いただけません。追加の利用枠が必要な場合はプランをアップグレードしてください。",
  AUTH_FAILED: "認証に失敗しました。再度ログインしてください。",
  WAF_BLOCKED:
    "セキュリティシステムによりアクセスがブロックされました。しばらく待ってから再度お試しください。",
  PERMISSION_DENIED: "画像生成の権限がありません。",
  GENERATION_CANCELLED: "生成をキャンセルしました。",
} as const;

一元管理にしたことで、メッセージ変更時の影響範囲が明確になりました。
たとえば WAF のエラーメッセージを変えたいとき、以前は grep で複数箇所を探していましたが、今は 1 ファイルを変更するだけです。

テスト側でも定数をインポートして照合できるため、メッセージの正確性を型レベルで保証できるようになりました。

改善 2: 型アサーション削除で型安全性を向上

2 つ目の指摘は as Proposal という型アサーションでした。
意外に見落としがちですが、as は TypeScript の型チェックをバイパスします。

Before(型アサーションあり):

export const createProposalFromText = (
  textProposal: GeneratedText,
  quotaRemaining?: number,
): Proposal => {
  const baseProposal: Omit<Proposal, "floorPlan" | "quotaRemaining"> = {
    // ...
  };
  return {
    ...baseProposal,
    quotaRemaining,
  } as Proposal;  // 型チェックがスキップされる
};

After(型システムに完全依存):

export const createProposalFromText = (
  textProposal: GeneratedText,
  quotaRemaining?: number,
): Proposal => {
  return {
    location: {
      title: textProposal.location.title,
      description: textProposal.location.description,
      imageIsLoading: false,
    },
    exterior: {
      title: textProposal.exterior.title,
      description: textProposal.exterior.description,
      imageIsLoading: false,
    },
    interior: {
      title: textProposal.interior.title,
      description: textProposal.interior.description,
      imageIsLoading: false,
    },
    quotaRemaining,
  };
};

as を削除した瞬間に、IDE の補完が正確に機能し始めます。
将来 Proposal 型にフィールドが追加されたとき、このコードはビルド時にエラーになります。
型アサーションがあった頃は、ランタイムまで気づけませんでした。

実際、過去に削除された floorPlan フィールドがオプショナル化されていたことも、この修正で初めて気づきました。
as があるとこういった型の変遷が見えなくなります。

改善 3: キャッシュヒットテストの完全実装

3 つ目は、未完成だったテストケースの実装です。
キャッシュヒット時の正常系テストが、モック設定不足のまま残っていました。

it("キャッシュヒット時は即座に complete に遷移する", async () => {
  const cachedProposal = {
    location: {
      title: "キャッシュ立地",
      description: "キャッシュ立地の説明",
      imageIsLoading: false,
    },
    exterior: {
      title: "キャッシュ外観",
      description: "キャッシュ外観の説明",
      imageUrl: "cached-exterior.jpg",
      imageIsLoading: false,
    },
    interior: {
      title: "キャッシュ内装",
      description: "キャッシュ内装の説明",
      imageUrl: "cached-interior.jpg",
      imageIsLoading: false,
    },
    quotaRemaining: 10,
  };

  mockGetCachedProposal.mockResolvedValueOnce({
    proposal: cachedProposal,
    generatedText: mockTextProposal,
  });

  const { result } = renderHook(() =>
    useProposalGeneration(createHookProps()),
  );

  await act(async () => {
    await result.current.generateProposal();
  });

  // キャッシュヒット → preparing を飛ばして complete へ
  expect(result.current.generationStage).toBe("complete");
  expect(result.current.proposal?.location.title).toBe("キャッシュ立地");

  // API は呼ばれない
  expect(mockGenerateProposalWithImages).not.toHaveBeenCalled();
});

キャッシュヒット時に API 呼び出しが発生しないことの確認は、パフォーマンスとコストの両面で重要です。
このテストがなかった頃は、キャッシュ周りの変更が不安でした。

リファクタリング結果

メトリクス Before After 改善
最大ファイル行数 798 行 364 行 54% 削減
モジュール数 1 8 責務分離完了
テストケース数 13 16 3 ケース追加
テスト通過率 100% 100% 維持
カバレッジ 80.8% 80.56% ほぼ維持
ビルド時間 823ms 高速

カバレッジがわずかに下がった(0.24pt)のは、分割によって新たに生まれた分岐パスがまだカバーされていないためです。
テスト通過率 100% を維持しながら分割できたのは、段階的に進めた成果だと感じています。

つまづいた点

キャッシュテストが「通っているように見えた」問題

リファクタリング中、既存のテストケースに「キャッシュヒット時」の検証が未完成のまま残っていることに気づきました。
mockGetCachedProposal のモック設定が抜けていたのですが、テスト自体はスキップされずに通過していたのです。

原因は、モックの戻り値が undefined のまま実行され、別のコードパス(キャッシュミス→ API 呼び出し)に流れていたことでした。
テストが通っていても意図した動作を検証できていない——これは怖いパターンです。

型アサーション削除時の型整合性確認

as Proposal を削除する際、Proposal 型の全フィールドが明示的に返されているか、一つひとつ確認する必要がありました。
以前削除されたフィールドがオプショナル化済みであることも、この作業で初めて判明しました。

型アサーションは「今は動く」のですが、将来の変更を隠してしまうリスクがあります。
削除するだけで、型システムが教えてくれる情報量が格段に増えました。

学んだこと: Union Type + Switch による網羅的エラー処理

分割後のエラーハンドリングでは、Union Type と switch 文の組み合わせを採用しました。

type ErrorType = "quota" | "auth" | "permission" | "network" | "waf" | "unknown";

function handleError(errorType: ErrorType): string {
  switch (errorType) {
    case "quota":
      return ERROR_MESSAGES.QUOTA_EXHAUSTED_DETAIL;
    case "auth":
      return ERROR_MESSAGES.AUTH_FAILED;
    case "permission":
      return ERROR_MESSAGES.PERMISSION_DENIED;
    case "network":
      return "ネットワークエラーが発生しました。";
    case "waf":
      return ERROR_MESSAGES.WAF_BLOCKED;
    case "unknown":
      return "予期しないエラーが発生しました。";
  }
}

TypeScript の --strictNullChecks と組み合わせると、ErrorType に新しい値を追加したときに switch 文で漏れがあればコンパイルエラーになります。
if-else の連鎖より安全で、エラーケースの追加漏れをコンパイル時に検出できます。

まとめ

743 行のモノリシックフックを 8 モジュールに分割し、以下の成果を得ました。

  • 最大ファイル行数 54% 削減: 修正箇所の特定が格段に速くなりました
  • 型アサーション削除: as を排除し、将来の型変更がビルド時に検出される体制に
  • エラーメッセージ一元管理: UI/UX の一貫性と保守性が向上
  • テスト 100% 通過を維持: 段階的に進めたことで、リグレッションゼロで完了

段階的なリファクタリングの鍵は「1 つのコミットで 1 つの責務を分離する」ことです。
一度に全部やろうとすると、テストの整合性が取れなくなって途中で破綻します。