【その②】生成AI時代のフロントエンドにおけるアーキテクチャの最適解
はじめに
前提
生成 AI 時代におけるフロントエンドにおけるアーキテクチャの最適解シリーズの記事では以下の構成を想定しており、今回はページのアーキテクチャについて解説をします。
- 1. コンポーネント
- 2. ページ ⭐
- 3. デザインシステム
- 4. Provider Context
- 5. features
- 6. その他・全体構成
アーキテクチャとは
いろいろ人によって解釈があると思いますが、僕の中では、IO と依存関係が定まっており、各パーツがいつでも交換可能に設計されている状況をアーキテクチャが整理されていると表現しています。
このブログでは、IO と依存関係が定まっており、各パーツがいつでも交換可能な構造がアーキテクチャであると定義します。
目次
- よくあるページの問題点
- 今回提案するアーキテクチャの全体像
- 解説
本題
よくあるページの問題点
-
- 合計 3000 行超!巨大モンスターページの登場
-
- ロジックに必要なのか、見た目に必要なのか判別できない!カオスな props リレー
-
- ページの入り口が不明確!URL パラメータ迷子の発生
-
- 秘伝のタレになったページ構造を丸ごとコピー!クローンページ増殖祭り
-
- 見た目の統一感ゼロ!ページ単位の無法地帯レスポンシブ
「でかい・重い・ぐちゃぐちゃ」の三拍子揃っていそうですね。
特に 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 点です。
- index.ts、container、presenter の Container / Presenter パターンを使用すること
- index.ts で ページに渡すべき Props を定義すること
- Container には UI を配置せず、modal のロジックや state 管理、handler の定義のみを行うこと
- 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 のみを修正できる
まとめ
今までたくさんのエンジニアに会ってきましたが、フロントエンドエンジニアでアーキテクチャを意識している方はほんとに稀です。
バックエンドはめっちゃ意識しているのに、フロントエンドでは全く意識していないひとも多いです。
僕の今回の記事で、フロントエンドでのアーキテクチャを意識していただければと思います。
次はその ③ でデザインシステム周りを詳しく解説していきます。
読んでいただきありがとうございました。
付録
参考記事
- 依存性の逆転のいちばんわかりやすい説明 2025/11/14 access
- Next.js/App Router を CleanArchitecture 風に構築してみた 2025/11/14 access
🤖 本記事における AI 活用レポート
この記事は、エンジニアの知見を核としながら、生産性向上のために生成 AI を積極的に活用しています。
-
活用目的:
- アイデアの壁打ち・構成案のブレインストーミング
- 専門用語の平易な表現への言い換え
- サンプルコードの生成・リファクタリング
- 記事全体の誤字脱字チェック
- 記事タイトル案の複数提示
-
使用ツール:
- Cursor Composer1
プロンプトハイライト
全体の文体や、名称揺れがないか確認してください
執筆者のコメント
カオスなプロジェクトをリファクタリングしていくのに、今回の記事がかなりやりやすいと思っています。是非是非参考にしていただけるとありがたいです。
💡 コードからクリニックへ:この記事の技術がもたらした価値
この記事で解説した「」は、以下の価値を生み出しました。
-
To ビジネス 📈
-
提供価値:
上記リファクタリングによって、Mobile アプリ対応やレスポンシブ対応がかなりやりやすくなって、3 ヶ月で Mobile アプリとレスポンシブなアプリを提供できた。 -
インパクト:
顧客体験の向上、売上の向上
-
提供価値:
📊 エンジニア・スコアカード:今回のソリューション自己評価
この記事で解説した「」について、担当エンジニアが 5 段階で自己評価しました。
- パフォーマンス: ★★★★★
- 保守性: ★★★★★
- テスト容易性: ★★★★★
- コスト効率: ★★★★★
- 革新性: ★☆☆☆☆
担当エンジニアの評価理由
取り組みやすいのと、大規模になりやすいページに対しての対策になるから、かなり評価が高いです。
Discussion