🧩

(React)コンテナー・プレゼンテーションパターンの設計を完全に理解した

2024/09/24に公開

概要

業務委託で4社以上経験がありますが、だいたいどこの現場でも、フロントエンドの設計は、「雰囲気で運用している」ことが多かったです。

なので、今回はコンテナープレゼンテーションパターンについて、本気出して考えてみました!

Reactの設計についてはこの動画がよかったです!

コンテナープレゼンテーションパターンは、この記事が一番しっくりきました。
https://zenn.dev/morinokami/books/learning-patterns-1/viewer/presentational-container-pattern#コンテナ・プレゼンテーションパターン

React において関心の分離 (separation of concerns) を実現する方法の一つとして、コンテナ・プレゼンテーションパターン (Container/Presentational pattern) があります。このパターンにより、ビューをアプリケーションロジックから分離することができます。

たとえば、6 枚の犬の画像を取得し、それらを画面に表示するアプリケーションを作成したいとします。

このプロセスを 2つに分けることで、関心の分離を図りたいと思います。

プレゼンテーションコンポーネント:
データがユーザーにどのように表示されるかを管理するコンポーネント。この例では、犬の画像のリストをレンダリングします。

コンテナコンポーネント:
何のデータがユーザーに表示されるかを管理するコンポーネント。

この例では、犬の画像を取得します。

イメージ

残念ながら、実際はもっと複雑なケースが多いです。
今回は、上記の記事から派生して少し複雑なケースの実装について明らかにしていきます。

前提

Next.js(App Router)は想定外とします。
プレゼンテーションから、サーバーコンポーネント呼び出すパターンもありえるらしいので。

対象読者

  • フロントエンド開発中の方
  • 設計についてあれこれ考えたい方

いいね!してね

この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

それでは以下が本編です。

結論

  • ドメインモデルを定義して、原則的に最小単位とする
  • コンテナープレゼンテーション間のI/Fを厳密に定義する。

説明すること

  • atomic designとの対応関係
  • 複雑なユースケースを実装する
  • ユースケースを分解して説明する

atomic designとの対応関係

一旦、atomic designベースでの対応表を見た方が、イメージしやすいかもしれません。
※chatGPTの回答を少し修正した一般的な例です。

今回対象になるのは、主にOrganisms、Moleculesという認識です。

レイヤー 説明 APIリクエストの実行可否 コンテナー・プレゼンテーションパターン フォルダー名の例 同レイヤーを参照可能
Pages 完成されたページ全体、ユーザーが実際にアクセスする部分。 ◯ ページの初期ロードなどで実行する場合も コンテナー pages, screens, views ◯ 可能
Templates ページのレイアウトや構造を定義するコンポーネント。 △ 必要に応じてOrganismsの結果を使用する コンテナー templates, layouts ◯ 可能
Organisms 複数のMoleculesやAtomsを組み合わせた複雑なUIコンポーネント。 ◯ APIリクエストを実行することが多い コンテナー organisms, widgets ◯ 可能
Molecules 複数のAtomsを組み合わせたコンポーネント(例: フォーム)。 ✕ 実行しない プレゼンテーション molecules, parts ◯ 可能
Atoms ボタン、ラベル、入力フィールドなどの最小単位のUI要素。 ✕ 実行しない プレゼンテーション atoms, elements ✕ 不可

複雑なユースケースを実装する

実際の要件でありそうなユースケースを用意しました。

ユースケース

要件

  • APIでメンバーリストを取得したのち、メンバーのステータスに応じて別のステータスに変更できます。
  • アクションボタンを押すことで、ステータスを変更できます(APIを呼びます)
    • 「アクティブ」ステータス → 「BAN」「Disable」ステータスに変更できます。
    • 「アクティベーション中」ステータス → 「BAN」ステータスに変更できます。

UI

  • リストはテーブル形式で表示します
  • 行ごとのアクションボタン押下 -> ポップアップ -> dialog (+ form)

ストーリーブック(動作確認用)
https://nextjs-tdd-templatestorybook-mf6gjvaes-rsugis-projects.vercel.app/?path=/story/feature-admin-allmembers-index--一覧

ソースコード
https://github.com/r-sugi/nextjs-tdd-template/tree/90c9f757d409ec9b077d6af58dd96c9e30d1527d

依存関係図

制約をつける

typescriptを使って、下記の制約を設けました。

  1. レイヤーで処理を制約する
    3層(リポジトリ、ユースケース、ビュー)に分ける
    ビュー内をさらに分ける
  2. ドメインモデルで型を制約する
    フロントエンドのアプリケーション用のモデルを定義する(特定の状態の時だけ処理をするという要求に応える)
  3. propsで型を制約する
    graphql Fragmentを使わない(graphqlの型をそのまま使うとキツいことが多い)
    モデルの一部のプロパティのみを渡さない(原則、ドメインモデルが最小単位)
    型の再定義をしない(特に関数!)
  4. コンテナープレゼンテーション間のI/F制約をする(callback関数 + EventType)

これらに触れつつ、「データ取得」「表示」「イベント発火」までの流れを具体的なコードとともに説明していきます。

ユースケースを分解して説明する

下記の順番に説明します。

  1. コンテナー内でhooksを使ってデータを取得する
  2. コンテナーからプレゼンテーション(ルート)にデータを渡す
  3. プレゼンテーション(ルート)からプレゼンテーションにデータを渡す
  4. プレゼンテーションでイベントを発火させて、データを上位コンポーネントに渡す

1.コンテナー内でhooksを使ってデータを取得する

該当箇所

1.1 コンテナーでユースケース(hooks)を使ってメンバーリストを取得します。

下記のようにステータスごとにモデルを作っておき、AllMembersというリストを返すようにしています。

app/src/core/domains/member/member.ts
import type { ActiveMember } from "./activeMember";
import type { BannedMember } from "./bannedMember";
import type { PendingActivationMember } from "./pendingActivationMember";
import type { ResignMember } from "./resignMember";
import type { RestoredMember } from "./restoredMember";

// ステータスごとにユニオンで増えていく
export type AllMember =
	| ActiveMember
	| ResignMember
	| BannedMember
	| RestoredMember
	| PendingActivationMember;

export type AllMembers = AllMember[];

1.2 モデルへの変換

GraphQLはxx.graphqlファイルからhooksを生成する時点で「ドメインモデル」と捉えて、リポジトリ層内で変換処理をしています(REST APIの場合はユースケース層で変換するのかな、、?)

※併せて、この層で「graphql特有のdataを取得完了するまでnullableになる型」を潰しています

app/src/core/repositories/member/transformer/allMembers.transformer.ts
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { BannedMember } from "@/core/domains/member/bannedMember";
import type { AllMembers } from "@/core/domains/member/member";
import type { PendingActivationMember } from "@/core/domains/member/pendingActivationMember";
import type { ResignMember } from "@/core/domains/member/resignMember";
import type { RestoredMember } from "@/core/domains/member/restoredMember";
import type { GetAllMembersQueryResult } from "@/generated/graphql";

export const transform = (res: GetAllMembersQueryResult): AllMembers | null => {
    // 取得完了するまでnullableになる型を潰す
	if (res.data == null) {
		return null;
	}
    // 取得完了するまでnullableになる型を潰す
	if (res.data.memberStatusActivityLatest == null) {
		return null;
	}

	const result = res.data.memberStatusActivityLatest.map((activity) => {
		if (activity?.memberActive) {
			const memberActive: ActiveMember = {
				status: "active",
				statusActivityId: activity.memberActive.statusActivityId,
				memberId: activity.memberActive.memberId,
				email: activity.memberActive.email,
				address: activity.memberActive.address,
				postalCode: activity.memberActive.postalCode,
				createdAt: new Date(activity.memberActive.createdAt),
				birthday: new Date(activity.memberActive.birthday),
			};
			return memberActive;
		}
		if (activity?.memberBanned) {
			const memberBanned: BannedMember = {
				...activity.memberBanned,
				status: "banned",
				createdAt: new Date(activity.memberBanned.createdAt),
			};
			return memberBanned;
		}
		if (activity?.memberPendingActivation) {
			const memberPendingActivation: PendingActivationMember = {
				...activity.memberPendingActivation,
				status: "pendingActivation",
				createdAt: new Date(activity.memberPendingActivation.createdAt),
			};
			return memberPendingActivation;
		}
		if (activity?.memberResigned) {
			const memberResigned: ResignMember = {
				...activity.memberResigned,
				status: "resigned",
				reasonDetail: activity.memberResigned.reasonDetail ?? undefined,
				createdAt: new Date(activity.memberResigned.createdAt),
			};
			return memberResigned;
		}
		if (activity?.memberRestored) {
			const memberRestored: RestoredMember = {
				...activity.memberRestored,
				status: "restored",
				createdAt: new Date(activity.memberRestored.createdAt),
			};
			return memberRestored;
		}
		return undefined;
	});
	return result.filter((e) => !!e);
};

ドメインモデルに変換する理由
フロントエンドはバックエンドAPIとは異なるドメインでロジックを組み立てるためです。

バックエンドAPIは基本的にstatusごとに型をつけて返してきません。
なので、GraphQLのレスポンス型(fragmentを含む)をそのまま使うのはNGと思っています

response_example.ts
// バックエンドAPIが返すmembersのイメージ
apiResponse = {
	data:[
        // 認証完了前ステータス
		{
			status: "pendingActivation",
			statusActivityId: "1",
			memberId: "1",
		},
       // アクティブステータス
		{
			status: "active",
			statusActivityId: "1",
			memberId: "1",
			address: "東京都",
			postalCode: "123456",
		},
	]
}

今回のようにstatusごとに次のアクションが異なるから表示するUIを変える場合、
フロントエンド側でドメインモデル(つまり型)を定義しておくべきです。

※こうしておくと、型定義と同じファイルにロジックを集約できます。

app/src/core/domains/member/activeMember.ts
import { AllMember } from "./member";
import type { memberStatus } from "./status";
// DBから取得したentityを扱うための型定義
export type ActiveMember = {
	status: (typeof memberStatus)["active"];
	statusActivityId: string;
	memberId: string;
	address: string;
	postalCode: string;
	email: string;
	birthday: Date;
	createdAt: Date;
};

// TODO: 型定義と同じファイル内にドメインロジックをかく例
export const updateStatus = (member: AllMember, status: memberStatus) => {
	member.status = "active";
	return member;
}

リポジトリ層(or ユースケース層)でモデルの型を定義して変換しておくと、後続処理(ユースケース、ビュー)で型制限できます。今後の要件追加や変更時に対応しやすくなるでしょう。

export type AllMember =
	| ActiveMember
	| ResignMember
	| BannedMember
	| RestoredMember
	| PendingActivationMember
    ... ステータスのパターンが増えるかもしれない
    ... ステータス x 別のプロパティで判定したくなったときは専用の型を作る

補足資料: 「一休」さんの登壇資料
https://speakerdeck.com/naoya/guan-shu-xing-puroguramingutoxing-sisutemunomentarumoderu?slide=53

2.コンテナーからプレゼンテーション(ルート)にデータを渡す

よくある例です。

app/src/feature/admin/allMembers/index/index.tsx
import type { FC } from "react";

import { useFetchAllMembers } from "@/core/usecases/member/useFetchAllMembers.query";

import { MemberTableRow } from "./table/MemberTableRow";
import { MemberTable } from "./table/Table";
import { type OnSubmitStatusChange, eventTypes } from "./table/type";

export const IndexTemplate: FC = () => {
	const { data, loading } = useFetchAllMembers();

	if (loading) {
		return <div>loading...</div>;
	}
	if (data?.members === null) {
		return (
			<div data-testid="admin-members-index-error">
				CSR リクエストエラー: データ取得に失敗しました
			</div>
		);
	}

	// Event
	const onSubmit: OnSubmitStatusChange = (payload) => {
		if (payload.type === eventTypes.onClickBan) {
			console.log("API処理", payload.detail);
		} else if (payload.type === eventTypes.onClickDisable) {
			console.log("API処理", payload.detail);
		}
	};

	return (
		<div data-testid="admin-members-index">
			<MemberTable>
				{data.members.map((member) => (
					<MemberTableRow
						member={member} // AllMember型
						key={member.statusActivityId}
						onSubmit={onSubmit}
					/>
				))}
			</MemberTable>
		</div>
	);
};

プレゼンテーション(ルート)側が定義したI/Fに従って、コンテナー内にonSubmit関数を定義します。
if文で条件分岐した際に、キーバリュー(type, value)の組み合わせで型推論が効くようにしましょう。

一部抜粋
	// ルートとのやりとりは、type, detailのI/Fで型制約する。
	const onSubmit: OnSubmitStatusChange = (payload) => {
		if (payload.type === eventTypes.onClickBan) {
			// detailの型 OnClickBanValue が推論される
			console.log("API処理", payload.detail);
		} else if (payload.type === eventTypes.onClickDisable) {
			// detailの型 OnClickDisableValue が推論される
			console.log("API処理", payload.detail);
		}
	};

NG: 引数(や返り値)にだけ型をつけるケースはダメです。
(props側で再度型定義することになる、型をimportしたり複雑になるため)

// 引数にだけ型をつけるケース
const onSubmit: OnSubmitStatusChange = (payload: XXXX): YYY => {

上記の型定義は少し大変だけど、書くとロジックがシンプルになります。
イベントタイプが増えたら、キーバリューの組み合わせを増やす想定です。

app/src/feature/admin/allMembers/index/table/type.tsの一部
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { PendingActivationMember } from "@/core/domains/member/pendingActivationMember";
import type { BanMemberSchema } from "../form/useBanMemberForm";

//
// Payload
//

export type EventType = (typeof eventTypes)[keyof typeof eventTypes];

export const eventTypes = {
	onClickBan: "onClickBan",
	onClickDisable: "onClickDisable",
} as const;

type OnClickBanValue = {
	member: ActiveMember | PendingActivationMember;
	reason: string;
};

type OnClickDisableValue = {
	member: ActiveMember;
};

type EventValues = {
	[eventTypes.onClickBan]: OnClickBanValue;
	[eventTypes.onClickDisable]: OnClickDisableValue;
};

type BasePayload<T extends EventType, U extends EventValues[T]> = {
	type: T;
	detail: U;
};

type BanPayload = BasePayload<"onClickBan", OnClickBanValue>;
type DisablePayload = BasePayload<"onClickDisable", OnClickDisableValue>;

type Payload = BanPayload | DisablePayload;

//
// Event Handler
//

export type OnSubmitStatusChange = (payload: Payload) => void;

// ...割愛

3.プレゼンテーション(ルート)からプレゼンテーションにデータを渡す

いくつかファイルを抜粋して、紹介します。詳しく見たい人はソースコードを見てください。

関数レベルで型定義したので、プレゼンテーション(子)側では型定義が楽になりました。

app/src/feature/admin/allMembers/index/table/MemberTableRow.tsxの一部
type Prop = {
	member: AllMember;
	onSubmit: OnSubmitStatusChange; // 関数に対する型を再定義しなくて済むようになった
    // onSubmit: (payload: Event) => void; みたいに都度再定義するパターンをよく見かける
};

export const MemberTableRow: FC<Prop> = ({ member, onSubmit }) => {

関数の型を指定する際にGenerics部分に型を入れるだけになりました!

app/src/feature/admin/allMembers/index/table/MemberTableRow.tsxの一部
export const MemberTableRow: FC<Prop> = ({ member, onSubmit }) => {
	const [dialogState, setToggleModal] =
		useState<DialogState>(initialDialogState);

	const openModal = useCallback((eventType: EventType) => {
		setToggleModal((prev) => ({ ...prev, [eventType]: true }));
	}, []);

	const closeModal = useCallback((eventType: EventType) => {
		setToggleModal((prev) => ({ ...prev, [eventType]: false }));
	}, []);
	
	// Banのクリック処理はActiveMemberのみを対象としている
	const onClickBan: OnClickBan<ActiveMember | PendingActivationMember> = () => {
		openModal("onClickBan");
	};

	// disableのクリック処理はActiveMemberのみを対象としている
	const onClickDisable: OnClickDisable<ActiveMember> = () => {
		openModal("onClickDisable");
	};

	// Banのクリック処理はActiveMember or PendingActivationMemberを対象としている
	const onSubmitBan: OnSubmitBan<ActiveMember | PendingActivationMember> = (
		member,
		data,
	) => {
		closeModal("onClickBan");

		onSubmit({
			type: eventTypes.onClickBan,
			detail: {
				member,
				reason: data.reason,
			},
		});
	};
	// Banのクリック処理はActiveMember を対象としている
	const onSubmitDisable: OnSubmitDisable<ActiveMember> = (member) => {
		return () => {
			closeModal("onClickDisable");

			onSubmit({
				type: eventTypes.onClickDisable,
				detail: { member },
			});
		};
	};

propsの型定義がすごく簡単に済みます

app/src/feature/admin/allMembers/index/dialog/DisableDialog.tsx
import { Button, Dialog } from "@/components";
import { DialogBody } from "@/components/dialog/Dialog";
import type { ActiveMember } from "@/core/domains/member/activeMember";
import type { OnSubmitDisable } from "../table/type";

type props = {
	opened: boolean;
	member: ActiveMember; // ドメインモデルが渡ってくる
	onSubmitDisable: OnSubmitDisable<ActiveMember>; // Genericsに型を指定するだけですむ
	onClose: () => void;
};

export const DisableDialog = ({
	opened,
	member,
	onSubmitDisable,
	onClose,
}: props) => {
	return (
		<Dialog open={opened} handleClose={onClose}>
			<DialogBody>
				{/* bodytitle */}
				<h2
					className="text-std-24B-5 desktop:text-std-28B-5"
					id="example-heading1"
				>
					Disable status is:{member.status}
				</h2>
				{/* body */}
				<p className="text-std-16N-7">
					ダイアログの補助テキストが入ります。ダイアログの補助テキストが入ります。
				</p>
				{/* form */}
				<div className="mt-2 flex w-full max-w-xs flex-col gap-4 desktop:mt-6">
					{/* action */}
					<Button onClick={onSubmitDisable(member)} size="lg" variant="primary">
						無効化する
					</Button>
					<Button onClick={onClose} size="lg" variant="tertiary">
						キャンセル
					</Button>
				</div>
			</DialogBody>
		</Dialog>
	);
};

モデルの一部のプロパティのみを渡さない。つまり (原則、ドメインモデルを最小単位) としたいです。

NG: モデルの一部のプロパティのみを渡すケース

app/src/feature/admin/allMembers/index/table/MemberTableRow.tsxの一部
    // ドメインモデルを最小単位としたケース
	const onSubmitDisable: OnSubmitDisable<ActiveMember> = (member) => {
		return () => {
			closeModal("onClickDisable");

			onSubmit({
				type: eventTypes.onClickDisable,
				detail: { member },
			});
		};
	};

    // 引数にモデルの一部プロパティだけを渡すケース(NG)
	const onSubmitDisable = (memberId: string) => {
		return () => {
			closeModal("onClickDisable");

			onSubmit({
				type: eventTypes.onClickDisable,
				detail: { member },
			});
		};
	};

まとめ

厳密にI/Fを決めることで、全体的にプレゼンテーション処理がシンプルになる一方で、型定義ファイルがどんどんボリューミーになっていきました。

で、最終的に

「型定義すること」=「実装」では? という気づきがありました。

ライブラリを作る時(使う時)と近い感覚でしょうか。

ただ、型定義でつまづくと開発が先に進まないので、Typescriptに精通したエンジニアがいないと厳しいかもしれません。

実際は今回の例よりもっと複雑なパターン(コンテナーからコンテナーを呼ぶとか、コンテナーから複数のプレゼンテーションルートを呼ぶとか)が多い印象です。

そのときは今回の例をベースに考えていきたいですね!


この記事の事例は必要に応じて今後追記していく予定です!
「新しい事例が知りたい」「他の事例も知りたい」と思った人は、ぜひこの記事にいいね👍してください。筆者のモチベーションにつながります!

Discussion