🗂

React Router環境におけるContainer/Presentationalパターンの導入

に公開

はじめに

こんにちは、ツクリンクでソフトウェアエンジニアをしているてつです。

ツクリンクではフロントエンド環境にReactRouter(v7)を採用しはじめており、Railsフロントエンド環境からの移行を進めています。
今回、テストやStorybook対応を容易にするために、Container/Presentationalパターンを採用しました。本記事では、その実装のポイントを紹介します。

Container/Presentationalパターンとは

Container/Presentationalパターンは、Reactコンポーネントを以下の2つの層に分離する設計パターンです。

  • Container Component: ビジネスロジック、データ取得、状態管理を担当
  • Presentational Component: UI表示のみに専念、propsを通じてデータを受け取る

導入の背景と目的

解決したかった課題

  1. テストの複雑さ: データ取得ロジックとUI表示が混在し、テストのモックが複雑になる
  2. Storybookにおける課題: APIで取得する情報など、外部依存があるコンポーネントの表示が複雑になる
  3. 責務の曖昧さ: ビジネスロジックとUIロジックの境界が不明確であることによるテストコードの実装スコープの曖昧さ

期待した効果

  1. テストコードの簡素化: データ取得ロジックのテストをUI表示のテストから独立させることでテストをシンプルにできる
  2. Storybookでの表示容易性: データ取得ロジックを分離することでStorybookの表示をシンプルにできる
  3. 責務の明確化: ビジネスロジックとUIロジックの境界が明確になる

実装ルールの策定

ファイル命名規則

一貫性を保つために、以下の命名規則を策定しました。

- Container: `xxx-container.tsx`
  例: threads-container.tsx
- Presentational: `xxx-presentation.tsx`
  例: threads-presentation.tsx

コンポーネント名もファイル名に準拠します。

// ThreadsContainer, ThreadsPresentation

作成粒度の指針

過度な分離を避けるため、以下の原則を設定しました。

  • 明確にContainerが必要な場合のみ: 単純なコンポーネントは分離しない
  • propsのバケツリレーを許容: テストの容易性を優先する

テスト方針

  • Container: ロジックを含まない場合(値を加工せずに渡すだけなど)はテスト不要
  • Presentational: Storybook・単体テストを必須で記述

実装例

Container Component

threads-container.tsx
import { useCallback } from "react";
import { ThreadsPresentation } from "./threads-presentation";
import { useMessageData } from "~/routes/messages/hooks/use-message-data";

export const ThreadsContainer = () => {
  const { threads, currentThread, isSearchOpen, isLoading } = useMessageData();

  // ビジネスロジック
  const readLatestThread = useCallback(() => {
    // データ取得処理
    console.log("readLatestThread");
  }, []);

  // データの整形・加工
  const isEmptyThread = !isLoading && !threads.length;

  return (
    <ThreadsPresentation
      currentThread={currentThread}
      isSearchOpen={isSearchOpen}
      onReadLatestThread={readLatestThread}
      isEmptyThread={isEmptyThread}
    />
  );
};

Presentational Component

threads-presentation.tsx
import { useEffect, useState } from "react";
import type { Thread } from "~/features/messages/demo/types";
import { useDeviceSize } from "~/hooks";
import { Box } from "~/libs/mui/material";

type ThreadsPresentationProps = {
  currentThread: Thread | null;
  isSearchOpen: boolean;
  onReadLatestThread: () => void;
  isEmptyThread?: boolean;
};

export const ThreadsPresentation = ({
  currentThread,
  isSearchOpen,
  onReadLatestThread,
  isEmptyThread,
}: ThreadsPresentationProps) => {
  const { isMobileSize } = useDeviceSize();
  const [isInitialized, setIsInitialized] = useState(false);

  // UI ロジックのみ
  useEffect(() => {
    if (!isInitialized) {
      setIsInitialized(true);
      return;
    }

    if (!isMobileSize && currentThread?.id === undefined) {
      onReadLatestThread();
    }
  }, [isMobileSize, currentThread?.id, onReadLatestThread, isInitialized]);

  // 純粋な表示ロジック
  if (isMobileSize && currentThread?.id !== undefined) return null;
  if (isMobileSize && isEmptyThread) return null;

  return (
    <Box sx={/* スタイル定義 */}>
      {isSearchOpen ? (
        <ThreadsSearch />
      ) : (
        <>
          <ThreadsHeaderContainer />
          <ThreadsListContainer />
        </>
      )}
    </Box>
  );
};

Presentational内でのContainer使用について

上記の例では、ThreadsPresentation内でThreadsHeaderContainerThreadsListContainerといったContainerコンポーネントを使用しています。これは一見、「PresentationalコンポーネントはUI表示のみ」という原則に反するように見えるかもしれません。

しかし、これは コンポーネントの合成(Composition) における自然な設計です。

  • ThreadsPresentationレイアウトとUI構造の責務を持つ
  • 子コンポーネントが何であるかは関知せず、純粋に構造的な配置のみを担当
  • 各子Containerコンポーネントは、それぞれ独立したデータ取得とロジックを持つ

この設計により、各機能領域(ヘッダー、リスト)の責務を明確に分離しつつ、全体のレイアウト構造は一箇所で管理できます。

Storybookとの親和性

Presentational Componentは外部依存がないため、Storybookでの表示が非常に簡単になります。

threads-presentation.stories.tsx
export const Default: Story = {
  args: {
    currentThread: createTestThread(1),
    isSearchOpen: false,
    onReadLatestThread: fn(),
  },
};

モックデータの準備も最小限で済み、UIの視覚的確認が容易になりました。

テスト戦略

Presentational Componentのテスト

threads-presentation.test.ts
describe("ThreadsPresentation", () => {
  test("デスクトップで選択されたスレッドが表示される", () => {
    mockUseDeviceSize.mockReturnValue({ isMobileSize: false });
    
    render(
      <ThreadsPresentation
        currentThread={mockThread}
        isSearchOpen={false}
        onReadLatestThread={vi.fn()}
      />
    );

    expect(screen.getByTestId("threads-header")).toBeInTheDocument();
    expect(screen.getByTestId("threads-list")).toBeInTheDocument();
  });
});

外部依存がpropsに限定されているため、モックが非常にシンプルになります。

Container Componentのテスト

threads-container.test.ts
describe("ThreadsContainer", () => {
  test("ThreadsPresentationに正しいpropsが渡される", () => {
    const mockData = { 
      threads: [],
      currentThread: null,
      isSearchOpen: false,
      isLoading: false
    };
    vi.mocked(useMessageData).mockReturnValue(mockData);

    render(<ThreadsContainer />);

    expect(ThreadsPresentation).toHaveBeenCalledWith(
      expect.objectContaining({
        currentThread: null,
        isSearchOpen: false,
        isEmptyThread: true,
      }),
      {}
    );
  });
});

導入後の効果と課題

プラスの効果

  1. テストカバレッジの向上: Presentationalコンポーネントのテストが容易になり、UIテストの網羅性が向上
  2. Storybookカタログの充実: UIコンポーネントの視覚的確認が可能になり、デザインシステムの構築が進む
  3. 開発効率の向上: 責務が明確になり、並行開発が容易になる
  4. 保守性の向上: 変更の影響範囲が限定的になる

潜在的な課題

  1. ファイル数の増加: Container/Presentationalで2ファイル必要になる
  2. 分離の境界: 状態やロジックをどこに持つか、人によって書き方が変わる可能性

リスク軽減策

  • 必要最小限の箇所でのみ適用: 過度な分離を避ける
  • 粒度ガイドラインの遵守: チーム内での統一したルール策定もしくはスプリントプランニングで粒度の認識を共有する

まとめ

Container/Presentationalパターンの導入により、テストの書きやすさとStorybookでの表示容易性が大幅に向上しました。特に以下の点で効果を実感しています。

  1. 外部依存のモック化が簡素化され、テスト作成の負担が軽減
  2. UIコンポーネントの独立性が高まり、再利用性が向上
  3. 責務の明確化により、コードの可読性と保守性が向上

ただし、すべてのコンポーネントで適用する必要はなく、データ取得が発生する箇所や複雑なビジネスロジックを含む箇所で選択的に適用することが重要です。

このパターンを導入することで、より堅牢で保守しやすいReactアプリケーションの構築が可能になります。テストとStorybookを意識したコンポーネント設計の参考にしていただければ幸いです。

Discussion