🤖

【その②】生成AI時代のフロントエンドにおけるアーキテクチャの最適解

に公開

はじめに

前提

生成 AI 時代におけるフロントエンドにおけるアーキテクチャの最適解シリーズの記事では以下の構成を想定しており、今回はページのアーキテクチャについて解説をします。

  • 1. コンポーネント
  • 2. ページ
  • 3. デザインシステム
  • 4. Provider Context
  • 5. features
  • 6. その他・全体構成

アーキテクチャとは

いろいろ人によって解釈があると思いますが、僕の中では、IO と依存関係が定まっており、各パーツがいつでも交換可能に設計されている状況をアーキテクチャが整理されていると表現しています。
このブログでは、IO と依存関係が定まっており、各パーツがいつでも交換可能な構造がアーキテクチャであると定義します。

目次

  1. よくあるページの問題点
  2. 今回提案するアーキテクチャの全体像
  3. 解説

本題

よくあるページの問題点

    1. 合計 3000 行超!巨大モンスターページの登場
    1. ロジックに必要なのか、見た目に必要なのか判別できない!カオスな props リレー
    1. ページの入り口が不明確!URL パラメータ迷子の発生
    1. 秘伝のタレになったページ構造を丸ごとコピー!クローンページ増殖祭り
    1. 見た目の統一感ゼロ!ページ単位の無法地帯レスポンシブ

「でかい・重い・ぐちゃぐちゃ」の三拍子揃っていそうですね。

特に 3000 行あるような巨大モンスターページなんて絶対改修したくない、、心臓に悪い

今回提案するアーキテクチャの全体像

上記を解決するために、Container / Presenter パターンと、依存性の逆転を使用したページシステム を提案します。

以下具体例と、ユースケースも含めて利点を紹介していきます。

pages/home/
        ├── container.tsx
        ├── index.ts
        └── presenter.tsx

また pages は、下の層から渡される、URL や認可の情報・共通化された Layout をもとに情報を表示する責務を持っていると定義します。

index.ts

export { Container as HomePage } from "./container";

export type HomePageProps = {
  searchId: string;
};

container.tsx

...
import { useLogger } from '@/compositionRoot/logger';
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import type { HomePageProps } from '.';
import { Presenter } from './presenter';

export type PresenterProps = {
  searchId: string;
  onBannerClick: () => void;
  canonicalUrl: string;
  title: string;
};

export const Container = ({ searchId }: HomePageProps) => {
  const location = useLocation();
  const logger = useLogger();
  const hasLoggedRef = useRef(false);

  useEffect(() => {
    if (!hasLoggedRef.current) {
      logger.logNavigation('Page viewed', {
        page: 'HomePage',
        navigation: {
          from: null,
          to: location.pathname + location.search,
          method: 'DIRECT_URL',
        },
        action: USER_ACTIONS.view,
        component: 'HomePage',
      });
      hasLoggedRef.current = true;
    }
  }, [location.pathname, location.search, logger]);

  const handleBannerClick = () => {
    window.location.href = '...';
  };

  const canonicalUrl = generateCanonicalUrl('/home');
  const title = '...';

  const props: PresenterProps = {
    searchId: searchId,
    onBannerClick: handleBannerClick,
    canonicalUrl,
    title,
  };

  return <Presenter {...props} />;
};

presenter.tsx

...
import type { PresenterProps } from "./container";
import {
  CardWithLinkSmall,
  DisplayContent,
  HomeBanner,
  LazyImage,
  PageTitle,
  RoundedBorderButton,
  RoundedFilledButton,
  StripesWithCheckImage,
} from '@/components';
...

export const Presenter = ({ searchId, onBannerClick, canonicalUrl, title }: PresenterProps) => {
  return (
    <>
      ...
      <div className="vertual-office-tour__container">
        <LazyImage
          src="/home-bg.png"
          alt="reward bg"
        />
        <LazyImage
          src="/home-mask.png"
          alt="reward bg"
        />
        <div className="vertual-office-tour__outer">
          <div className="vertual-office-tour__content">
            <DisplayContent
              src="https://tour.vachanavi.net/tour-d77b0e9597754a94b856c2c281e7a9be"
              alt="vertual office tour"
              className="vertual-office-tour-display"
              loading="eager"
              fetchPriority="high"
              decoding="async"
            />
            <div className="vertual-office-tour-description">
              <div className="vertual-office-tour-description__content">
                <p className="vertual-office-tour-description__content-message">
                  ...
                  <RoundedFilledButton
                    label={"新規会員登録"}
                    onClick={() => {
                      throw new Error("Function not implemented.");
                    }}
                    variant="primary"
                    size="large"
                  />
                </p>
              </div>
              <LazyImage
                src={TopVr}
                alt="アイキャッチ画像"
                className="vertual-office-tour-description__content-vr"
                loading="lazy"
                decoding="async"
              />
            </div>
          </div>
        </div>
        {/* <LazyImage
          src={bg}
          alt="装飾された背景画像"
          className="vertual-office-tour-bg"
          loading="lazy"
          decoding="async"
        />
        <LazyImage
          src={bgMask}
          alt="装飾された背景画像マスク"
          className="vertual-office-tour-bg-mask"
          loading="lazy"
          decoding="async"
        /> */}
      </div>
    </>
  );
};

解説

今回意識すべき点は 4 点です。

  1. index.ts、container、presenter の Container / Presenter パターンを使用すること
  2. index.ts で ページに渡すべき Props を定義すること
  3. Container には UI を配置せず、modal のロジックや state 管理、handler の定義のみを行うこと
  4. Presenter には UI のみを配置すること

index.ts、container、presenter の Container / Presenter パターンを使用すること

Container / Presenter パターンを利用した以下のようなディレクトリ構成を推奨します。
以下のように分割をするだけで、責任範囲が明確になり、合計 3000 行超!巨大モンスターページの登場がかなり抑制されます。

pages/home/
        ├── container.tsx
        ├── index.ts
        └── presenter.tsx

index.ts
ページのインタフェース
依存性の逆転を元に、{PageName}Props を定義します。

container.tsx

ページの実態、{PageName}Props に依存します。
依存性の逆転を元に、PresenterProps を定義します。

presenter.tsx
PresenterProps に依存します。

依存関係図

index.ts で ページに渡すべき Props を定義すること

前回の記事でも説明した内容ですが、外部からこのページを見られた時の姿形を index.ts に定義しておくことが最重要です。

このファイルを作成するだけで、ページの入り口が不明確!URL パラメータ迷子の発生が抑制でき、かなり整理されます。

// export { Container as HomePage } from "./containerA"; // Version Aのページ
export { Container as HomePage } from "./containerB"; // Version Bのページ

export type HomePageProps = {
  searchId: string;
};

上記のように外部から見て、HomePage として定義してあれば、A/B テストなどで切り変えしやすい。
URL クエリや、/{**Id}などのパラメータは Props から取得することを前提とする。
ページの責務は、外部からページを表示するための情報を受け取り、API を叩き、UI を配置する。
また、アクセス認可などの処理はページが表示される前に行われるべきなので、ページ内で行わない。

URL パラメータの変換例

React Router の場合、ルーター側でパラメータを抽出して Props として渡します:

// React Router の場合
// ルーター側の設定例
<Route
  path="/home/:searchId"
  element={<HomePage searchId={useParams().searchId} />}
/>;

// または、HOC やラッパーコンポーネントで変換
const HomePageWrapper = () => {
  const { searchId } = useParams();
  return <HomePage searchId={searchId!} />;
};

Container には UI を配置せず、modal のロジックや state 管理、handler の定義のみを行うこと

Container は、ロジックに必要なProps を受け取り、表示・管理に必要なProps を作成して Presenter に渡します。
責務分離が明確なことで、ロジックに必要なのか、見た目に必要なのか判別できない!カオスな props リレーのデメリットがかなり抑制でき、可読性が向上します。

具体例:Container での実装パターン

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { usePageModal } from "@/hooks/usePageModal";
import { useSearchData } from "@/hooks/useSearchData";
import type { HomePageProps } from ".";
import { Presenter } from "./presenter";

export type PresenterProps = {
  searchId: string;
  // API呼び出し関連
  searchData: SearchData | undefined;
  isLoading: boolean;
  error: Error | null;
  // Modal関連
  isModalOpen: boolean;
  onOpenModal: () => void;
  onCloseModal: () => void;
  // State管理関連
  selectedItemId: string | null;
  onSelectItem: (id: string) => void;
  // Handler
  onBannerClick: () => void;
  canonicalUrl: string;
  title: string;
};

export const Container = ({ searchId }: HomePageProps) => {
  // 1. API呼び出し(hooks を展開)
  const { data: searchData, isLoading, error } = useSearchData(searchId);

  // 2. State管理
  const [selectedItemId, setSelectedItemId] = useState<string | null>(null);

  // 3. ページ単位 Modal の管理(hooks を展開)
  const { isOpen: isModalOpen, openModal, closeModal } = usePageModal();

  // 4. Handler の定義
  const handleBannerClick = () => {
    window.location.href = "...";
  };

  const handleOpenModal = () => {
    openModal("confirm");
  };

  const handleSelectItem = (id: string) => {
    setSelectedItemId(id);
    // 必要に応じて他のロジックも実行
  };

  // 5. ロジックに基づいた値の計算
  const canonicalUrl = generateCanonicalUrl("/home");
  const title = searchData?.title ?? "デフォルトタイトル";

  // 6. Presenter に渡す Props を構築
  const props: PresenterProps = {
    searchId,
    searchData,
    isLoading,
    error,
    isModalOpen,
    onOpenModal: handleOpenModal,
    onCloseModal: closeModal,
    selectedItemId,
    onSelectItem: handleSelectItem,
    onBannerClick: handleBannerClick,
    canonicalUrl,
    title,
  };

  return <Presenter {...props} />;
};

ポイント:

  • src/hooks 等に定義された Page 用の hooks を展開する
  • ページ単位 Modal の管理 hooks は、container で展開し、handler として presenter に渡すと管理がしやすい
  • State 管理は Container で行い、必要な値のみを Presenter に渡す
  • API 呼び出しの結果(データ、ローディング状態、エラー)も Container で管理し、Presenter に渡す

Presenter には UI のみを配置すること

Presenter には Components/以下の UI、後述する features/〜/sections の UI を配置する。
UI とロジックを明確に分け、コード量を減らすことでレスポンシブ対応における影響範囲や、テストのしやすさを提供します。
Container / Presenter パターンが見た目の統一感ゼロ!ページ単位の無法地帯レスポンシブの明確な対応策になります。

具体例:Presenter での実装パターン

import type { PresenterProps } from "./container";
import { LoadingSpinner, ErrorMessage, ConfirmModal } from "@/components";

export const Presenter = ({
  searchId,
  searchData,
  isLoading,
  error,
  isModalOpen,
  onOpenModal,
  onCloseModal,
  selectedItemId,
  onSelectItem,
  onBannerClick,
  canonicalUrl,
  title,
}: PresenterProps) => {
  // ローディング状態の表示
  if (isLoading) {
    return <LoadingSpinner />;
  }

  // エラー状態の表示
  if (error) {
    return <ErrorMessage error={error} />;
  }

  return (
    <>
      <Head>
        <title>{title}</title>
        <link rel="canonical" href={canonicalUrl} />
      </Head>

      {/* レスポンシブ対応の例 */}
      <div className="responsive-container">
        {/* モバイル専用のレイアウト */}
        <div className="mobile-only">
          <MobileLayout data={searchData} onSelectItem={onSelectItem} />
        </div>

        {/* デスクトップ専用のレイアウト */}
        <div className="desktop-only">
          <DesktopLayout data={searchData} onSelectItem={onSelectItem} />
        </div>
      </div>

      {/* Modal の表示(Container から渡された handler を使用) */}
      <ConfirmModal
        isOpen={isModalOpen}
        onClose={onCloseModal}
        title="確認"
        message="この操作を実行しますか?"
      />

      {/* その他の UI コンポーネント */}
      <Banner onClick={onBannerClick} />
    </>
  );
};

レスポンシブ対応の利点:

  • UI のみを配置することで、レスポンシブ対応時の影響範囲が明確になる
  • モバイル/デスクトップで異なるレイアウトが必要な場合も、Presenter 内で完結できる
  • Container のロジックを変更せずに、UI のみを修正できる

まとめ

今までたくさんのエンジニアに会ってきましたが、フロントエンドエンジニアでアーキテクチャを意識している方はほんとに稀です。

バックエンドはめっちゃ意識しているのに、フロントエンドでは全く意識していないひとも多いです。

僕の今回の記事で、フロントエンドでのアーキテクチャを意識していただければと思います。

次はその ③ でデザインシステム周りを詳しく解説していきます。

読んでいただきありがとうございました。

付録

参考記事

🤖 本記事における AI 活用レポート

この記事は、エンジニアの知見を核としながら、生産性向上のために生成 AI を積極的に活用しています。

  • 活用目的:

    • アイデアの壁打ち・構成案のブレインストーミング
    • 専門用語の平易な表現への言い換え
    • サンプルコードの生成・リファクタリング
    • 記事全体の誤字脱字チェック
    • 記事タイトル案の複数提示
  • 使用ツール:

    • Cursor Composer1
プロンプトハイライト

全体の文体や、名称揺れがないか確認してください

執筆者のコメント

カオスなプロジェクトをリファクタリングしていくのに、今回の記事がかなりやりやすいと思っています。是非是非参考にしていただけるとありがたいです。

💡 コードからクリニックへ:この記事の技術がもたらした価値

この記事で解説した「」は、以下の価値を生み出しました。

  • To ビジネス 📈
    • 提供価値:
       上記リファクタリングによって、Mobile アプリ対応やレスポンシブ対応がかなりやりやすくなって、3 ヶ月で Mobile アプリとレスポンシブなアプリを提供できた。
    • インパクト:
      顧客体験の向上、売上の向上

📊 エンジニア・スコアカード:今回のソリューション自己評価

この記事で解説した「」について、担当エンジニアが 5 段階で自己評価しました。

  • パフォーマンス: ★★★★★
  • 保守性: ★★★★★
  • テスト容易性: ★★★★★
  • コスト効率: ★★★★★
  • 革新性: ★☆☆☆☆
担当エンジニアの評価理由

取り組みやすいのと、大規模になりやすいページに対しての対策になるから、かなり評価が高いです。

Discussion