Blogへ戻る

Language: 日本語

「React コンポーネントが肥大化していませんか?」 表示判定とロジックを純粋な Model に切り出してテスト容易性を手に入れるフロントエンド設計

React でアプリケーションを開発していると、**「いつの間にか 1 つのコンポーネントが何百行にも膨らみ、何をしているのか追えなくなってしまった……」** という事態によく遭遇します。

React でアプリケーションを開発していると、「いつの間にか 1 つのコンポーネントが何百行にも膨らみ、何をしているのか追えなくなってしまった……」 という事態によく遭遇します。

  • 「このボタンを disabled にする条件って何だっけ?」と JSX の奥深くにある複雑な三項演算子を解読する
  • ちょっとした表示フラグの分岐をテストしたいだけなのに、毎回 @testing-library/react で DOM をマウントしてモックを何重にも組まなければならない
  • UI の見た目を少し微調整しただけで、ロジック側のテストまで巻き込まれて壊れる

ディレクトリ構造の整理(『「features ディレクトリ」が巨大なゴミ箱になっていませんか? 再帰的 features と ts-morph で依存を縛るフロントエンド設計』)と並行して、肥大化の温床となっていた巨大な画面コンポーネントの大規模なリファクタリングを行いました。

この記事では、コンポーネントが抱え込んでいた 「状態の加工・表示の判断」を純粋関数(Model)へと切り出し、UI を単なる描画レイヤーへと薄くする設計 と、それを 40 件近くの PR に分割して安全にやり遂げた実践プロセスを解説します。

得られること

  • コンポーネントの肥大化を防ぎ、表示判断と描画を綺麗に分離する ViewState / Model 設計 がわかる
  • DOM レンダリングを一切行わずに、高速かつ網羅的な単体テストを書く方法が学べる
  • 巨大なコンポーネントをデグレを起こさずに漸進的に解体する 「1 PR 1 判断」の分割リファクタリング手法 が身につく!

1. コンポーネントが抱え込みがちな「判断」の正体

複雑な画面コンポーネントを観察すると、JSX による描画そのものよりも、「描画に至るまでの様々なビジネス判断やデータ加工」 に大半のコードが費やされていることがわかります。

実際に画面コンポーネントが抱え込んでいた判断には、次のようなものがありました。

  • 操作可否の判定: 現在のタスクが取得・操作可能(claimable)かどうか
  • 状態の分類: タスクが完了や破棄などの終了状態(terminal status)かどうか
  • エラーのハンドリング: API fetch 失敗時のエラー種別の分類とメッセージ組み立て
  • 次アクションの計算: 次のタスクに必要な入力データのプランニング
  • UI 表示フラグ: 初回チュートリアルモーダルを出すべきかどうかの判定
  • 操作制御: ショートカットキーをロックすべきかのコンテキスト判定
  • 履歴・永続化: 履歴スタックからの復元や localStorage との差分同期

これらがコンポーネントの中に直接書かれていると、「状態の保持」「ビジネスルールの判定」「DOM へのマッピング」が密結合 し、コードの見通しが極端に悪化します。

2. Before / After: 判断を純粋な Model(入出力関数)へ移す

この問題を解決するアプローチが、「コンポーネントからすべての判断を剥ぎ取り、純粋関数としての Model に委託する」 設計です。

Before: 描画と同じ場所で判断している状態

// Before: コンポーネントの中に判定ロジックが散乱している
export function TaskView({ task, user, history }: Props) {
  // コンポーネントの中で都度判定を行っている
  const canClaim = task.status === 'READY' && user.role === 'OPERATOR' && !task.lockedBy;
  const showTutorial = user.tutorialCompletedAt === null && task.type === 'ONBOARDING';
  const isTerminal = ['COMPLETED', 'REJECTED', 'CANCELLED'].includes(task.status);

  return (
    <div>
      {showTutorial && <TutorialModal />}
      <TaskHeader isTerminal={isTerminal} />
      <ActionButtons canClaim={canClaim} />
    </div>
  );
}

この状態では、canClaim の条件判定をテストしたいだけでも、React コンポーネント全体をレンダリングして DOM 要素の状態をアサートする必要があります。

After: 純粋関数で ViewState を組み立てる

// After: 判断は純粋関数(Model)に集約し、コンポーネントは結果を流し込むだけ
export interface TaskViewState {
  canClaim: boolean;
  showTutorial: boolean;
  isTerminal: boolean;
}

// 純粋な入力(データ)から純粋な出力(表示用状態)を作る Model 関数
export function buildTaskViewState(
  task: Task,
  user: User
): TaskViewState {
  return {
    canClaim: task.status === 'READY' && user.role === 'OPERATOR' && !task.lockedBy,
    showTutorial: user.tutorialCompletedAt === null && task.type === 'ONBOARDING',
    isTerminal: ['COMPLETED', 'REJECTED', 'CANCELLED'].includes(task.status),
  };
}

// コンポーネントは受け取った ViewState を描画するだけの Presentation レイヤーになる
export function TaskView({ task, user }: Props) {
  const viewState = buildTaskViewState(task, user);

  return (
    <div>
      {viewState.showTutorial && <TutorialModal />}
      <TaskHeader isTerminal={viewState.isTerminal} />
      <ActionButtons canClaim={viewState.canClaim} />
    </div>
  );
}

コンポーネントは「純粋な描画」に徹し、ビジネス判断はすべて buildTaskViewState などの純粋関数(Model)に閉じ込められます。

3. テスト容易性の劇的な向上

判断を Model に切り出す最大のメリットは、「テストの圧倒的な書きやすさと実行速度」 です。

DOM のマウントやコンテキストプロバイダーのセットアップは一切不要になり、単なる JavaScript オブジェクトの入出力テストになります。

// DOM を一切マウントせずに、判定ロジックの全分岐をミリ秒でテストできる!
describe('buildTaskViewState', () => {
  it('タスクが READY かつ未ロックなら操作可能と判定されること', () => {
    const task = createMockTask({ status: 'READY', lockedBy: null });
    const user = createMockUser({ role: 'OPERATOR' });

    const state = buildTaskViewState(task, user);

    expect(state.canClaim).toBe(true);
  });

  it('チュートリアル未完了かつ ONBOARDING タスクならモーダル表示フラグが true になること', () => {
    const task = createMockTask({ type: 'ONBOARDING' });
    const user = createMockUser({ tutorialCompletedAt: null });

    const state = buildTaskViewState(task, user);

    expect(state.showTutorial).toBe(true);
  });
});

テスト実行速度が数十倍〜数百倍に高速化されるだけでなく、エッジケース(境界値テストや未定義データのハンドリング)のテストケースを簡単かつ網羅的に追加できるようになります。

4. 「1 PR 1 判断」で進める安全なリファクタリング

巨大なコンポーネントのリファクタリングで最も避けるべきなのは、「すべてを一気に書き直す巨大 PR」を作ること です。 差分が数千行に及ぶと、レビューで意図を追うことが不可能になり、本番環境で確実にデグレを引き起こします。

今回は、「1 つの PR で 1 つの判断だけを切り出す」 というルールを徹底し、40 件近くの小さな PR に分けて作業を進めました。

  1. PR 1: judgeClaimable のロジックを Model に切り出し、単体テストを追加
  2. PR 2: judgeTutorial のロジックを切り出し、単体テストを追加
  3. PR 3: エラー分類ロジックを切り出し、単体テストを追加
  4. ……これを愚直に 40 回繰り返す

小さく刻むメリット

  • 差分の意図が明確: レビュアーは「今回の PR は claimable 判定の場所を変えただけだな」と 5 分で確実にレビューできる
  • 安全なロールバック: 万が一問題が発生しても、該当する 1 つの小さな PR を revert するだけで済む
  • 並行開発との衝突回避: 差分が小さいため、他の機能開発を行っているメンバーの作業とコンフリクトしにくい

振る舞い(Behavior)を一切変えずに構造(Structure)だけを変えるリファクタリングだからこそ、極小単位で刻むアプローチが絶大な効果を発揮します。

まとめ: コンポーネントを「描画」に集中させる

React アプリケーションを健全に保つための鍵は、「コンポーネントに判断をさせない」 ことです。

  1. 判断の切り出し: 状態の加工や条件判定を純粋関数(Model / ViewState 構築関数)に集約する
  2. 描画の単純化: コンポーネントは計算済みの ViewState を JSX にマッピングするだけの薄いレイヤーにする
  3. 高速な単体テスト: DOM レンダリングを排除し、純粋な入出力テストでロジックを強固に保護する
  4. 極小 PR の継続: 巨大なリライトを避け、「1 PR 1 判断」のサイクルで安全に移行する

コンポーネントから不要な責務を削ぎ落とすことで、コードの見通しが良くなり、将来の仕様変更やリファクタリングにもびくともしない堅牢なフロントエンド基盤が手に入ります。

参考リンク